diff --git a/.github/workflows/pr-checklist.yml b/.github/workflows/pr-checklist.yml index 724142b99..4818cabd7 100644 --- a/.github/workflows/pr-checklist.yml +++ b/.github/workflows/pr-checklist.yml @@ -30,9 +30,9 @@ jobs: const BODY_MARKER = ''; // PRs opened via the API/CLI (incl. AI agents) bypass the repository - // template, so the checklist never appears. When it is absent we inject - // this into the body once; editing the body re-fires this workflow, - // which then enforces the checkbox state. + // template, so the checklist never appears. Inject it so the + // contributor has boxes to check; GITHUB_TOKEN body edits do not + // re-fire this workflow, so the same run must also close below. const TEMPLATE = [ BODY_MARKER, '## Tested on', @@ -78,8 +78,8 @@ jobs: const common = { owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }; // The checklist is partly or wholly absent (template was bypassed). - // Inject it once so the contributor actually sees the boxes; the - // resulting body edit re-fires this workflow to enforce them. + // Inject it so the contributor sees the boxes, then fall through and + // close. A later human checkbox edit still retriggers `edited`. if (pr.state === 'open' && !allPresent && !body.includes(BODY_MARKER)) { await github.rest.pulls.update({ owner: context.repo.owner, @@ -87,8 +87,7 @@ jobs: pull_number: pr.number, body: body ? `${body}\n\n${TEMPLATE}` : TEMPLATE, }); - core.info(`Injected checklist template into PR #${pr.number}; awaiting completion.`); - return; + core.info(`Injected checklist template into PR #${pr.number}.`); } // Was this PR previously closed by the bot? Only those are eligible for auto-reopen. diff --git a/MC1/Intents/MessageTargetResolution.swift b/MC1/Intents/MessageTargetResolution.swift index 9c3cd2300..3ba372d04 100644 --- a/MC1/Intents/MessageTargetResolution.swift +++ b/MC1/Intents/MessageTargetResolution.swift @@ -3,20 +3,14 @@ import MC1Services // MARK: - Scope resolution -/// The radio and persistence store an intent query reads from. Resolves the -/// store explicitly because pickers populate while disconnected and in a -/// cold/background context with no live `ServiceContainer`: when connected the -/// live `dataStore`, otherwise a standalone store over the same open container. -/// A `nil` current radio (no connection and nothing last-connected, or a -/// pre-launch bridge with no `AppState`) yields nil, which every caller maps to -/// an empty result rather than an error. +/// The radio and persistence store an intent query reads from. Uses the +/// process-lifetime store on `ConnectionManager` because pickers populate +/// while disconnected. A `nil` radio yields nil; callers map that to empty. @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) + return (radioID, appState.connectionManager.persistenceStore) } // MARK: - Channel resolution diff --git a/MC1/Resources/AppShortcuts.xcstrings b/MC1/Resources/AppShortcuts.xcstrings index 6058de8b0..ade3f0db0 100644 --- a/MC1/Resources/AppShortcuts.xcstrings +++ b/MC1/Resources/AppShortcuts.xcstrings @@ -41,6 +41,12 @@ "value" : "Wyślij ogłoszenie w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar um Advert em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -101,6 +107,12 @@ "value" : "Wyślij ogłoszenie ${reach} w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar um Advert ${reach} em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -161,6 +173,12 @@ "value" : "Sprawdź stan radia w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificar o estado do rádio em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -221,6 +239,12 @@ "value" : "Czy moje radio jest połączone w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "O meu rádio está ligado em ${applicationName}?" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -281,6 +305,12 @@ "value" : "Wyślij wiadomość do ${target} w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar uma mensagem a ${target} em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -341,6 +371,12 @@ "value" : "Wyślij wiadomość w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar uma mensagem em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -401,6 +437,12 @@ "value" : "Jaki jest poziom baterii radia w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qual é a bateria do meu rádio em ${applicationName}?" + } + }, "ru" : { "stringUnit" : { "state" : "translated", diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 034a7fae7..d0b4c5947 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -137,7 +137,7 @@ public enum L10n { /// Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button public static let addSelected = L10n.tr("Chats", "chats.channelInfo.region.addSelected", fallback: "Add") /// Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set - public static let allRegions = L10n.tr("Chats", "chats.channelInfo.region.allRegions", fallback: "All Regions") + public static let allRegions = L10n.tr("Chats", "chats.channelInfo.region.allRegions", fallback: "Unscoped") /// Location: ChannelInfoSheet.swift - Purpose: Discover button public static let discover = L10n.tr("Chats", "chats.channelInfo.region.discover", fallback: "Discover Nearby Regions") /// Location: ChannelInfoSheet.swift - Purpose: Discover button loading state @@ -585,6 +585,10 @@ public enum L10n { } } public enum Message { + /// Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text + public static func translatedAccessibility(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.translatedAccessibility", String(describing: p1), fallback: "Translated: %@") + } /// Location: ChatConversationView.swift - Placeholder when message data is unavailable public static let unavailable = L10n.tr("Chats", "chats.message.unavailable", fallback: "Message unavailable") /// Location: ChatConversationView.swift - Accessibility label for unavailable message @@ -640,6 +644,12 @@ public enum L10n { public static let sendAgain = L10n.tr("Chats", "chats.message.action.sendAgain", fallback: "Send Again") /// Location: MessageActionsSheet.swift - Purpose: Action to start a DM with the channel sender public static let sendDM = L10n.tr("Chats", "chats.message.action.sendDM", fallback: "Send DM") + /// Location: BubbleTranslationControl.swift - Restore the stored message body + public static let showOriginal = L10n.tr("Chats", "chats.message.action.showOriginal", fallback: "Show original") + /// Location: BubbleTranslationControl.swift - In-bubble Translation offer + public static let translate = L10n.tr("Chats", "chats.message.action.translate", fallback: "Translate") + /// Location: BubbleTranslationControl.swift - Translation in progress + public static let translating = L10n.tr("Chats", "chats.message.action.translating", fallback: "Translating") /// Location: UnifiedMessageBubble.swift - VoiceOver action to open an attached image full-screen public static let viewImage = L10n.tr("Chats", "chats.message.action.viewImage", fallback: "View Image") /// Location: UnifiedMessageBubble.swift - Context menu action to view path @@ -684,6 +694,10 @@ public enum L10n { public static func received(_ p1: Any) -> String { return L10n.tr("Chats", "chats.message.info.received", String(describing: p1), fallback: "Received: %@") } + /// Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names + public static func regionAmbiguous(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.info.regionAmbiguous", String(describing: p1), fallback: "Region (ambiguous): %@") + } /// Location: MessageActionsSheet.swift - Details row shown when region could not be resolved public static let regionUnresolved = L10n.tr("Chats", "chats.message.info.regionUnresolved", fallback: "Region: Unknown") /// Location: UnifiedMessageBubble.swift - Context menu text showing round trip time - %d is milliseconds @@ -714,6 +728,22 @@ public enum L10n { public static func accessibilityLabel(_ p1: Any) -> String { return L10n.tr("Chats", "chats.message.region.accessibilityLabel", String(describing: p1), fallback: "Region %@") } + /// Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names + public static func ambiguousAccessibilityLabel(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.region.ambiguousAccessibilityLabel", String(describing: p1), fallback: "Region, ambiguous: %@") + } + public enum Ambiguous { + /// Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names + public static func popoverBody(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.region.ambiguous.popoverBody", String(describing: p1), fallback: "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used.%@") + } + /// Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match + public static let popoverTitle = L10n.tr("Chats", "chats.message.region.ambiguous.popoverTitle", fallback: "Multiple matching regions") + /// Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match + public static let possibleMatch = L10n.tr("Chats", "chats.message.region.ambiguous.possibleMatch", fallback: "Multiple matching regions") + /// Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match + public static let possibleMatchHint = L10n.tr("Chats", "chats.message.region.ambiguous.possibleMatchHint", fallback: "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used.") + } } public enum Repeat { /// Location: UnifiedMessageBubble.swift - Plural form of repeats @@ -921,6 +951,8 @@ public enum L10n { } } public enum Row { + /// Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator + public static let failedSend = L10n.tr("Chats", "chats.row.failedSend", fallback: "Failed to send") /// Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for favorite indicator public static let favorite = L10n.tr("Chats", "chats.row.favorite", fallback: "Favorite") /// Location: MutedIndicator.swift - Accessibility label for mentions-only indicator @@ -1225,6 +1257,8 @@ public enum L10n { public static let joinRoom = L10n.tr("Contacts", "contacts.detail.joinRoom", fallback: "Join Room") /// Location: ContactDetailView.swift - Purpose: Last advert label public static let lastAdvert = L10n.tr("Contacts", "contacts.detail.lastAdvert", fallback: "Last Advert") + /// Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label + public static let lastHeard = L10n.tr("Contacts", "contacts.detail.lastHeard", fallback: "Last Heard") /// Location: ContactDetailView.swift - Purpose: Location section header public static let location = L10n.tr("Contacts", "contacts.detail.location", fallback: "Location") /// Location: ContactDetailView.swift - Purpose: Management button @@ -1346,6 +1380,24 @@ public enum L10n { public static let removePhoto = L10n.tr("Contacts", "contacts.detail.avatar.removePhoto", fallback: "Remove Photo") /// Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved public static let savingAnnouncement = L10n.tr("Contacts", "contacts.detail.avatar.savingAnnouncement", fallback: "Saving photo") + public enum Crop { + /// Location: AvatarCropView.swift - Purpose: Crop screen confirm button + public static let choose = L10n.tr("Contacts", "contacts.detail.avatar.crop.choose", fallback: "Choose") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down + public static let moveDown = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveDown", fallback: "Move down") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left + public static let moveLeft = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveLeft", fallback: "Move left") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right + public static let moveRight = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveRight", fallback: "Move right") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up + public static let moveUp = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveUp", fallback: "Move up") + /// Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview + public static let preview = L10n.tr("Contacts", "contacts.detail.avatar.crop.preview", fallback: "Profile picture crop") + /// Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move + public static let previewHint = L10n.tr("Contacts", "contacts.detail.avatar.crop.previewHint", fallback: "Swipe up or down to zoom. Use the actions to move the photo.") + /// Location: AvatarCropView.swift - Purpose: Crop screen navigation title + public static let title = L10n.tr("Contacts", "contacts.detail.avatar.crop.title", fallback: "Move and Scale") + } } public enum Error { /// Location: ContactDetailView.swift - Purpose: Clear messages services-unavailable error @@ -2590,9 +2642,9 @@ public enum L10n { return L10n.tr("Localizable", "error.wifi.connectionFailed", String(describing: p1), fallback: "Connection failed: %@") } /// Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out - public static let connectionTimeout = L10n.tr("Localizable", "error.wifi.connectionTimeout", fallback: "Connection timed out. Check the IP address and ensure the device is on the same network.") - /// Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid - public static let invalidHost = L10n.tr("Localizable", "error.wifi.invalidHost", fallback: "Invalid IP address.") + public static let connectionTimeout = L10n.tr("Localizable", "error.wifi.connectionTimeout", fallback: "Connection timed out. Check the hostname or IP address and ensure the device is reachable.") + /// Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid + public static let invalidHost = L10n.tr("Localizable", "error.wifi.invalidHost", fallback: "Invalid hostname or IP address.") /// Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid public static let invalidPort = L10n.tr("Localizable", "error.wifi.invalidPort", fallback: "Invalid port number.") /// Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection info missing @@ -2725,6 +2777,8 @@ public enum L10n { public static let hopSingular = L10n.tr("Map", "map.detail.hopSingular", fallback: "1 hop") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp public static let lastAdvert = L10n.tr("Map", "map.detail.lastAdvert", fallback: "Last Advert") + /// Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label + public static let lastHeard = L10n.tr("Map", "map.detail.lastHeard", fallback: "Last Heard") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for latitude coordinate public static let latitude = L10n.tr("Map", "map.detail.latitude", fallback: "Latitude") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for longitude coordinate @@ -3060,7 +3114,7 @@ public enum L10n { public static let title = L10n.tr("Onboarding", "wifiConnection.title", fallback: "Connect via WiFi") public enum ConnectionDetails { /// Location: WiFiConnectionSheet.swift - Footer explaining connection details - public static let footer = L10n.tr("Onboarding", "wifiConnection.connectionDetails.footer", fallback: "Enter your MeshCore device's local network address. The default port is 5000.") + public static let footer = L10n.tr("Onboarding", "wifiConnection.connectionDetails.footer", fallback: "Enter your MeshCore device's hostname or IP address. The default port is 5000.") /// Location: WiFiConnectionSheet.swift - Section header for connection details public static let header = L10n.tr("Onboarding", "wifiConnection.connectionDetails.header", fallback: "Connection Details") } @@ -3069,10 +3123,10 @@ public enum L10n { public static let invalidPort = L10n.tr("Onboarding", "wifiConnection.error.invalidPort", fallback: "Invalid port number") } public enum IpAddress { - /// Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button - public static let clearAccessibility = L10n.tr("Onboarding", "wifiConnection.ipAddress.clearAccessibility", fallback: "Clear IP address") - /// Location: WiFiConnectionSheet.swift - Placeholder for IP address field - public static let placeholder = L10n.tr("Onboarding", "wifiConnection.ipAddress.placeholder", fallback: "IP Address") + /// Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button + public static let clearAccessibility = L10n.tr("Onboarding", "wifiConnection.ipAddress.clearAccessibility", fallback: "Clear hostname or IP") + /// Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field + public static let placeholder = L10n.tr("Onboarding", "wifiConnection.ipAddress.placeholder", fallback: "Hostname or IP") } public enum Port { /// Location: WiFiConnectionSheet.swift - Accessibility label for clear port button @@ -3538,16 +3592,30 @@ public enum L10n { public static let addRegion = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.addRegion", fallback: "Add Region") /// Location: RepeaterSettingsView.swift - Add region alert title public static let addRegionTitle = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.addRegionTitle", fallback: "Add Region") - /// Location: RepeaterSettingsView.swift - Wildcard region display name - public static let allTraffic = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTraffic", fallback: "All Traffic") - /// Location: RepeaterSettingsView.swift - Wildcard with asterisk display - public static let allTrafficWildcard = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTrafficWildcard", fallback: "* (All Traffic)") + /// Location: RepeaterSettingsView.swift - Unscoped region display name + public static let allTraffic = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTraffic", fallback: "Unscoped") + /// Location: RepeaterSettingsView.swift - Unscoped region with asterisk display + public static let allTrafficWildcard = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTrafficWildcard", fallback: "* (Unscoped)") + /// Location: RepeaterSettingsView.swift - Default scope picker label + public static let defaultScope = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.defaultScope", fallback: "Default Scope") + /// Location: RepeaterSettingsView.swift - Caption under default scope picker + public static let defaultScopeCaption = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.defaultScopeCaption", fallback: "Scopes packets this node originates, such as adverts. This setting saves to the repeater immediately.") + /// Location: RepeaterSettingsView.swift - Duplicate region name validation + public static let duplicate = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.duplicate", fallback: "This region already exists.") /// Location: RepeaterSettingsViewModel.swift - No regions on device public static let empty = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.empty", fallback: "No regions configured") /// Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle public static let floodToggleHint = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.floodToggleHint", fallback: "When off, flood packets from this region are dropped") /// Location: RepeaterSettingsView.swift - Home region picker label public static let homeRegion = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.homeRegion", fallback: "Home Region") + /// Location: RepeaterSettingsView.swift - Region name charset validation + public static let invalidName = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.invalidName", fallback: "Region names can only contain letters, numbers, and hyphens.") + /// Location: RepeaterSettingsView.swift - Region name length validation + public static func nameTooLong(_ p1: Int) -> String { + return L10n.tr("RemoteNodes", "remoteNodes.settings.regions.nameTooLong", p1, fallback: "Region names are limited to %d bytes.") + } + /// Location: RepeaterSettingsView.swift - No default scope + public static let noDefault = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.noDefault", fallback: "None") /// Location: RepeaterSettingsViewModel.swift - Region has children error public static let notEmpty = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.notEmpty", fallback: "Remove child regions first") /// Location: RepeaterSettingsView.swift - Region name placeholder diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index 1ce3eb91c..c9a190d27 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Noch keine Nachrichten"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Senden fehlgeschlagen"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Stumm"; @@ -562,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Kopieren"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Übersetzen"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Original anzeigen"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Wird übersetzt..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Übersetzt: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Wiederholungsdetails"; @@ -612,6 +627,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Unbekannt"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (mehrdeutig): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Details"; @@ -899,6 +917,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, mehrdeutig: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Mehrere passende Regionen"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Mehrere Regionen in deiner Liste passen zum Transportcode dieses Pakets. Die App kann nicht feststellen, welche verwendet wurde."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Mehrere passende Regionen"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Mehrere Regionen in deiner Liste passen zum Transportcode dieses Pakets. Die App kann nicht feststellen, welche verwendet wurde.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Sendezeit: %@"; @@ -1107,7 +1140,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Alle Regionen"; +"chats.channelInfo.region.allRegions" = "Ohne Bereich"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (Standard)"; diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 78180cd89..674b0c82a 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Diese Datei konnte nicht als Profilbild verwendet werden."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Bewegen und skalieren"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Auswählen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Zuschnitt des Profilbilds"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Zum Zoomen nach oben oder unten streichen. Mit den Aktionen das Foto verschieben."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Nach oben verschieben"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Nach unten verschieben"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Nach links verschieben"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Nach rechts verschieben"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Spitzname"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Letzte Ankündigung"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Zuletzt gehört"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Ungelesene Nachrichten"; diff --git a/MC1/Resources/Localization/de.lproj/Localizable.strings b/MC1/Resources/Localization/de.lproj/Localizable.strings index daa774d30..021ae66d9 100644 --- a/MC1/Resources/Localization/de.lproj/Localizable.strings +++ b/MC1/Resources/Localization/de.lproj/Localizable.strings @@ -328,7 +328,7 @@ "error.wifi.connectionFailed" = "Verbindung fehlgeschlagen: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Zeitüberschreitung bei der Verbindung. Überprüfe die IP-Adresse und stelle sicher, dass sich das Gerät im selben Netzwerk befindet."; +"error.wifi.connectionTimeout" = "Zeitüberschreitung bei der Verbindung. Überprüfe den Hostnamen oder die IP-Adresse und stelle sicher, dass das Gerät erreichbar ist."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Nicht mit dem Gerät verbunden."; @@ -339,8 +339,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Zeitüberschreitung beim Senden."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Ungültige IP-Adresse."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Ungültiger Hostname oder ungültige IP-Adresse."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Ungültige Portnummer."; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index 91405b2df..984b471c1 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Letzte Ankündigung"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Zuletzt gehört"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Öffentlicher Schlüssel"; diff --git a/MC1/Resources/Localization/de.lproj/Onboarding.strings b/MC1/Resources/Localization/de.lproj/Onboarding.strings index 68e336c7c..93c112e1c 100644 --- a/MC1/Resources/Localization/de.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/de.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Verbindungsdetails"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-Adresse"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostname oder IP-Adresse"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "IP-Adresse löschen"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Hostname oder IP-Adresse löschen"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Port löschen"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Gib die lokale Netzwerkadresse deines MeshCore-Geräts ein. Der Standardport ist 5000."; +"wifiConnection.connectionDetails.footer" = "Gib den Hostnamen oder die IP-Adresse deines MeshCore-Geräts ein. Der Standardport ist 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Verbinde..."; diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 6f59a00a0..e7b68e6ae 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -349,15 +349,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Im Repeater speichern, um Änderungen über Neustarts hinweg zu behalten."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Gesamter Verkehr"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Ohne Bereich"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Gesamter Verkehr)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Ohne Bereich)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Heimatregion"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Standardbereich"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Keine"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Bestimmt den Bereich für Pakete, die dieser Knoten selbst erzeugt, etwa Ankündigungen. Diese Einstellung wird sofort im Repeater gespeichert."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -388,6 +397,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Region konnte nicht hinzugefügt werden"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Regionsnamen dürfen nur Buchstaben, Zahlen und Bindestriche enthalten."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Regionsnamen sind auf %d Bytes begrenzt."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Diese Region existiert bereits."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Region konnte nicht entfernt werden"; diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index ab8cffc8a..b440a823d 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "No messages yet"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Failed to send"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Muted"; @@ -591,6 +594,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copy"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Translate"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Show original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Translating"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Translated: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Repeat Details"; @@ -641,6 +656,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Unknown"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (ambiguous): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Details"; @@ -748,6 +766,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, ambiguous: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Multiple matching regions"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Multiple matching regions"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Send time: %@"; @@ -1113,7 +1146,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "All Regions"; +"chats.channelInfo.region.allRegions" = "Unscoped"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (default)"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index a8cbd4b89..eedfe7cd2 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -284,6 +284,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file isn't a valid image */ "contacts.detail.avatar.invalidImage" = "That file couldn't be used as a profile picture."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Move and Scale"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Choose"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Profile picture crop"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Swipe up or down to zoom. Use the actions to move the photo."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Move up"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Move down"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Move left"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Move right"; + /* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ "contacts.detail.avatar.savingAnnouncement" = "Saving photo"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Last Advert"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Last Heard"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Unread Messages"; diff --git a/MC1/Resources/Localization/en.lproj/Localizable.strings b/MC1/Resources/Localization/en.lproj/Localizable.strings index 8e2270b4e..fb2bc56f6 100644 --- a/MC1/Resources/Localization/en.lproj/Localizable.strings +++ b/MC1/Resources/Localization/en.lproj/Localizable.strings @@ -335,7 +335,7 @@ "error.wifi.connectionFailed" = "Connection failed: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Connection timed out. Check the IP address and ensure the device is on the same network."; +"error.wifi.connectionTimeout" = "Connection timed out. Check the hostname or IP address and ensure the device is reachable."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Not connected to device."; @@ -346,8 +346,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Send operation timed out."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Invalid IP address."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Invalid hostname or IP address."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Invalid port number."; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index ac3a89d22..075d3f30d 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -77,6 +77,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Last Advert"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Last Heard"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Public Key"; diff --git a/MC1/Resources/Localization/en.lproj/Onboarding.strings b/MC1/Resources/Localization/en.lproj/Onboarding.strings index 0d1852731..68f401117 100644 --- a/MC1/Resources/Localization/en.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/en.lproj/Onboarding.strings @@ -237,11 +237,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Connection Details"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP Address"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostname or IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Clear IP address"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Clear hostname or IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -250,7 +250,7 @@ "wifiConnection.port.clearAccessibility" = "Clear port"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Enter your MeshCore device's local network address. The default port is 5000."; +"wifiConnection.connectionDetails.footer" = "Enter your MeshCore device's hostname or IP address. The default port is 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Connecting..."; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index f0484f7df..4b90a0232 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -349,15 +349,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "All Traffic"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Unscoped"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Unscoped)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Home Region"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Default Scope"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "None"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Scopes packets this node originates, such as adverts. This setting saves to the repeater immediately."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -388,6 +397,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Failed to add region"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Region names can only contain letters, numbers, and hyphens."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Region names are limited to %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "This region already exists."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index 7ab934290..c20d4e729 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Sin mensajes aún"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Error al enviar"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Silenciado"; @@ -562,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copiar"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traducir"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Mostrar original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Traduciendo..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Traducido: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Detalles de repetición"; @@ -612,6 +627,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Región: Desconocido"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Región (ambigua): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Detalles"; @@ -899,6 +917,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Región %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Región, ambigua: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Varias regiones coincidentes"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Más de una región de tu lista coincide con el código de transporte de este paquete. La app no puede determinar cuál se usó."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Varias regiones coincidentes"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Más de una región de tu lista coincide con el código de transporte de este paquete. La app no puede determinar cuál se usó.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Hora de envío: %@"; @@ -1103,7 +1136,7 @@ "chats.channelInfo.region" = "Región"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Todas las regiones"; +"chats.channelInfo.region.allRegions" = "Sin región"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (predeterminado)"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 003cc20bb..e3e8c6b71 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Ese archivo no se pudo usar como foto de perfil."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Mover y escalar"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Elegir"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Recorte de la foto de perfil"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Desliza hacia arriba o hacia abajo para hacer zoom. Usa las acciones para mover la foto."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Mover hacia arriba"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Mover hacia abajo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Mover a la izquierda"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Mover a la derecha"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Apodo"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Último anuncio"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Última escucha"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Mensajes sin leer"; diff --git a/MC1/Resources/Localization/es.lproj/Localizable.strings b/MC1/Resources/Localization/es.lproj/Localizable.strings index 89335b1f6..ce7fba622 100644 --- a/MC1/Resources/Localization/es.lproj/Localizable.strings +++ b/MC1/Resources/Localization/es.lproj/Localizable.strings @@ -332,7 +332,7 @@ "error.wifi.connectionFailed" = "Error de conexión: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Se agotó el tiempo de conexión. Comprueba la dirección IP y asegúrate de que el dispositivo esté en la misma red."; +"error.wifi.connectionTimeout" = "Se agotó el tiempo de conexión. Comprueba el nombre de host o la dirección IP y asegúrate de que el dispositivo sea accesible."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "No conectado al dispositivo."; @@ -343,8 +343,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Se agotó el tiempo del envío."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Dirección IP no válida."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nombre de host o dirección IP no válidos."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Número de puerto no válido."; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 829d64910..83b915275 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Último anuncio"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Última escucha"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Clave pública"; diff --git a/MC1/Resources/Localization/es.lproj/Onboarding.strings b/MC1/Resources/Localization/es.lproj/Onboarding.strings index bc116de15..1b28c59c4 100644 --- a/MC1/Resources/Localization/es.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/es.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Detalles de conexión"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Dirección IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nombre de host o IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Borrar dirección IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Borrar nombre de host o IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Puerto"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Borrar puerto"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Introduce la dirección de red local de tu dispositivo MeshCore. El puerto por defecto es 5000."; +"wifiConnection.connectionDetails.footer" = "Introduce el nombre de host o la dirección IP de tu dispositivo MeshCore. El puerto por defecto es 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Conectando..."; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 3a4cf39e1..226e66681 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -349,15 +349,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Usa Guardar en el repetidor para conservar los cambios tras reiniciar."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Todo el tráfico"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Sin región"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Todo el tráfico)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Sin región)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Región local"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Alcance predeterminado"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Ninguno"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Aplica el alcance a los paquetes que origina este nodo, como los anuncios. Este ajuste se guarda inmediatamente en el repetidor."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -388,6 +397,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Error al agregar la región"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Los nombres de región solo pueden contener letras, números y guiones."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Los nombres de región están limitados a %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Esta región ya existe."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Error al eliminar la región"; diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index 12784f288..621734e9d 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Aucun message pour l'instant"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Échec de l'envoi"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "En sourdine"; @@ -562,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copier"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traduire"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Afficher l'original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Traduction..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Traduit : %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Détails des répétitions"; @@ -612,6 +627,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Région : Inconnue"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Région (ambiguë) : %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Détails"; @@ -899,6 +917,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Région %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Région, ambiguë : %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Plusieurs régions correspondantes"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Plusieurs régions de votre liste correspondent au code de transport de ce paquet. L’app ne peut pas déterminer laquelle a été utilisée."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Plusieurs régions correspondantes"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Plusieurs régions de votre liste correspondent au code de transport de ce paquet. L’app ne peut pas déterminer laquelle a été utilisée.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Heure d'envoi: %@"; @@ -1103,7 +1136,7 @@ "chats.channelInfo.region" = "Région"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Toutes les régions"; +"chats.channelInfo.region.allRegions" = "Portée non assignée"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (par défaut)"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 3056a8fc4..f86aa1efc 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Ce fichier n'a pas pu être utilisé comme photo de profil."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Déplacer et recadrer"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Choisir"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Recadrage de la photo de profil"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Balayez vers le haut ou le bas pour zoomer. Utilisez les actions pour déplacer la photo."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Déplacer vers le haut"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Déplacer vers le bas"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Déplacer vers la gauche"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Déplacer vers la droite"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Surnom"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Dernière annonce"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Dernière écoute"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Messages non lus"; diff --git a/MC1/Resources/Localization/fr.lproj/Localizable.strings b/MC1/Resources/Localization/fr.lproj/Localizable.strings index fe6312a58..f2e90bccb 100644 --- a/MC1/Resources/Localization/fr.lproj/Localizable.strings +++ b/MC1/Resources/Localization/fr.lproj/Localizable.strings @@ -338,7 +338,7 @@ "error.wifi.connectionFailed" = "Échec de la connexion : %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "La connexion a expiré. Vérifiez l'adresse IP et assurez-vous que l'appareil est sur le même réseau."; +"error.wifi.connectionTimeout" = "La connexion a expiré. Vérifiez le nom d'hôte ou l'adresse IP et assurez-vous que l'appareil est joignable."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Non connecté à l'appareil."; @@ -349,8 +349,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "L'envoi a expiré."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Adresse IP non valide."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nom d'hôte ou adresse IP non valide."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Numéro de port non valide."; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 74abad884..b3e7526ef 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Dernière annonce"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Dernière écoute"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Clé publique"; diff --git a/MC1/Resources/Localization/fr.lproj/Onboarding.strings b/MC1/Resources/Localization/fr.lproj/Onboarding.strings index 92f762eb5..5b06be7e2 100644 --- a/MC1/Resources/Localization/fr.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/fr.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Détails de connexion"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Adresse IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nom d'hôte ou IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Effacer l'adresse IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Effacer le nom d'hôte ou l'adresse IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Effacer le port"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Entrez l'adresse réseau locale de votre appareil MeshCore. Le port par défaut est 5000."; +"wifiConnection.connectionDetails.footer" = "Entrez le nom d'hôte ou l'adresse IP de votre appareil MeshCore. Le port par défaut est 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Connexion..."; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index e06c0f007..6e1a38333 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -349,15 +349,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Enregistrez sur le répéteur pour conserver les modifications après les redémarrages."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Tout le trafic"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Portée non assignée"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Tout le trafic)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Portée non assignée)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Région d'origine"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Portée par défaut"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Aucune"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Applique cette portée aux paquets émis par ce nœud, comme les annonces. Ce réglage est enregistré immédiatement sur le répéteur."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -388,6 +397,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Échec de l'ajout de la région"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Les noms de région ne peuvent contenir que des lettres, des chiffres et des traits d'union."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Les noms de région sont limités à %d octets."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Cette région existe déjà."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Échec de la suppression de la région"; diff --git a/MC1/Resources/Localization/it.lproj/Chats.strings b/MC1/Resources/Localization/it.lproj/Chats.strings index 091355eef..1c2576647 100644 --- a/MC1/Resources/Localization/it.lproj/Chats.strings +++ b/MC1/Resources/Localization/it.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Ancora nessun messaggio"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Invio non riuscito"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Silenziato"; @@ -591,6 +594,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copia"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traduci"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Mostra originale"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Traduzione..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Tradotto: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Dettagli ripetizioni"; @@ -641,6 +656,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Sconosciuta"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (ambigua): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Dettagli"; @@ -748,6 +766,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, ambigua: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Più region corrispondenti"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Più region nell'elenco corrispondono al codice di trasporto di questo pacchetto. L'app non può stabilire quale sia stata usata."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Più region corrispondenti"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Più region nell'elenco corrispondono al codice di trasporto di questo pacchetto. L'app non può stabilire quale sia stata usata.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Ora di invio: %@"; @@ -1113,7 +1146,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Tutte le region"; +"chats.channelInfo.region.allRegions" = "Senza ambito"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (predefinita)"; diff --git a/MC1/Resources/Localization/it.lproj/Contacts.strings b/MC1/Resources/Localization/it.lproj/Contacts.strings index c4bee8c36..542b317ca 100644 --- a/MC1/Resources/Localization/it.lproj/Contacts.strings +++ b/MC1/Resources/Localization/it.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ "contacts.detail.avatar.savingAnnouncement" = "Salvataggio foto"; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Sposta e scala"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Scegli"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Ritaglio della foto del profilo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Scorri verso l'alto o il basso per lo zoom. Usa le azioni per spostare la foto."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Sposta in alto"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Sposta in basso"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Sposta a sinistra"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Sposta a destra"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Nickname"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Ultimo annuncio"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Ultimo ascolto"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Messaggi non letti"; diff --git a/MC1/Resources/Localization/it.lproj/Localizable.strings b/MC1/Resources/Localization/it.lproj/Localizable.strings index d5ec52c33..cefaff6bf 100644 --- a/MC1/Resources/Localization/it.lproj/Localizable.strings +++ b/MC1/Resources/Localization/it.lproj/Localizable.strings @@ -334,7 +334,7 @@ "error.wifi.connectionFailed" = "Connessione non riuscita: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Connessione scaduta. Controlla l'indirizzo IP e assicurati che il dispositivo sia nella stessa rete."; +"error.wifi.connectionTimeout" = "Connessione scaduta. Controlla il nome host o l'indirizzo IP e assicurati che il dispositivo sia raggiungibile."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Non connesso al dispositivo."; @@ -345,8 +345,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Operazione di invio scaduta."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Indirizzo IP non valido."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nome host o indirizzo IP non valido."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Numero di porta non valido."; diff --git a/MC1/Resources/Localization/it.lproj/Map.strings b/MC1/Resources/Localization/it.lproj/Map.strings index d79cdadf8..c32a10295 100644 --- a/MC1/Resources/Localization/it.lproj/Map.strings +++ b/MC1/Resources/Localization/it.lproj/Map.strings @@ -77,6 +77,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Ultimo annuncio"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Ultimo ascolto"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Chiave pubblica"; diff --git a/MC1/Resources/Localization/it.lproj/Onboarding.strings b/MC1/Resources/Localization/it.lproj/Onboarding.strings index fd691974a..a1d847ae0 100644 --- a/MC1/Resources/Localization/it.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/it.lproj/Onboarding.strings @@ -237,11 +237,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Dettagli connessione"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Indirizzo IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nome host o indirizzo IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Cancella indirizzo IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Cancella nome host o indirizzo IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Porta"; @@ -250,7 +250,7 @@ "wifiConnection.port.clearAccessibility" = "Cancella porta"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Inserisci l'indirizzo di rete locale del tuo dispositivo MeshCore. La porta predefinita è 5000."; +"wifiConnection.connectionDetails.footer" = "Inserisci il nome host o l'indirizzo IP del tuo dispositivo MeshCore. La porta predefinita è 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Connessione..."; diff --git a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings index 1e92ec8be..24914fb1f 100644 --- a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings @@ -349,15 +349,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Salva sul ripetitore per mantenere le modifiche tra i riavvii."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Tutto il traffico"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Senza ambito"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Tutto il traffico)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Senza ambito)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "region principale"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Ambito flood predefinito"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Nessuno"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Applica l'ambito ai pacchetti originati da questo nodo, come gli annunci. Questa impostazione viene salvata subito sul ripetitore."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -388,6 +397,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Aggiunta della region non riuscita"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "I nomi delle region possono contenere solo lettere, numeri e trattini."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "I nomi delle region sono limitati a %d byte."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Questa region esiste già."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Rimozione della region non riuscita"; diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index 87a4f4af1..6f7b0a409 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Nog geen berichten"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Verzenden mislukt"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Gedempt"; @@ -562,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Kopiëren"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Vertalen"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Origineel tonen"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Vertalen..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Vertaald: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Herhalingsdetails"; @@ -612,6 +627,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Regio: Onbekend"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Regio (dubbelzinnig): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Details"; @@ -899,6 +918,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Regio %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Regio, dubbelzinnig: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Meerdere overeenkomende regio's"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Meer dan één regio in je lijst komt overeen met de transportcode van dit pakket. De app kan niet bepalen welke is gebruikt."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Meerdere overeenkomende regio's"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Meer dan één regio in je lijst komt overeen met de transportcode van dit pakket. De app kan niet bepalen welke is gebruikt.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Verzendtijd: %@"; @@ -1103,7 +1138,7 @@ "chats.channelInfo.region" = "Regio"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Alle regio's"; +"chats.channelInfo.region.allRegions" = "Zonder regio"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (standaard)"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index d3c6f7346..d901e13b9 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Dit bestand kan niet als profielfoto worden gebruikt."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Verplaatsen en schalen"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Kies"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Uitsnede van profielfoto"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Veeg omhoog of omlaag om te zoomen. Gebruik de handelingen om de foto te verplaatsen."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Omhoog verplaatsen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Omlaag verplaatsen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Naar links verplaatsen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Naar rechts verplaatsen"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Bijnaam"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Laatste advertentie"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Laatst gehoord"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Ongelezen berichten"; diff --git a/MC1/Resources/Localization/nl.lproj/Localizable.strings b/MC1/Resources/Localization/nl.lproj/Localizable.strings index 59f9f9044..4285fc1d6 100644 --- a/MC1/Resources/Localization/nl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/nl.lproj/Localizable.strings @@ -339,7 +339,7 @@ "error.wifi.connectionFailed" = "Verbinding mislukt: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "De verbinding duurde te lang. Controleer het IP-adres en zorg dat het apparaat zich in hetzelfde netwerk bevindt."; +"error.wifi.connectionTimeout" = "De verbinding duurde te lang. Controleer de hostnaam of het IP-adres en zorg dat het apparaat bereikbaar is."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Niet verbonden met het apparaat."; @@ -350,8 +350,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Het verzenden duurde te lang."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Ongeldig IP-adres."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Ongeldige hostnaam of IP-adres."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Ongeldig poortnummer."; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index af458abfb..6f069090f 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Laatste advertentie"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Laatst gehoord"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Publieke sleutel"; diff --git a/MC1/Resources/Localization/nl.lproj/Onboarding.strings b/MC1/Resources/Localization/nl.lproj/Onboarding.strings index 409ad76c6..64d6fface 100644 --- a/MC1/Resources/Localization/nl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/nl.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Verbindingsgegevens"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-adres"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostnaam of IP-adres"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "IP-adres wissen"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Hostnaam of IP-adres wissen"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Poort"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Poort wissen"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Voer het lokale netwerkadres van je MeshCore-apparaat in. De standaardpoort is 5000."; +"wifiConnection.connectionDetails.footer" = "Voer de hostnaam of het IP-adres van je MeshCore-apparaat in. De standaardpoort is 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Verbinden..."; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index cca016675..09cb0fdec 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -349,15 +349,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Sla op naar repeater om wijzigingen na herstart te behouden."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Al het verkeer"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Zonder regio"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Al het verkeer)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Zonder regio)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Thuisregio"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Standaardregio"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Geen"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Past een regio toe op pakketten die dit knooppunt zelf verzendt, zoals advertenties. Deze instelling wordt meteen op de repeater opgeslagen."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -388,6 +397,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Kan regio niet toevoegen"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Regionamen mogen alleen letters, cijfers en koppeltekens bevatten."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Regionamen zijn beperkt tot %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Deze regio bestaat al."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Kan regio niet verwijderen"; diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index 3b8830f2b..78798ea9d 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Brak wiadomości"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Nie udało się wysłać"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Wyciszony"; @@ -557,6 +560,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Kopiuj"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Tłumacz"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Pokaż oryginał"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Tłumaczenie..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Przetłumaczono: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Szczegóły powtórzeń"; @@ -607,6 +622,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Nieznany"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (niejednoznaczny): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Szczegóły"; @@ -891,6 +910,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, niejednoznaczny: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Wiele pasujących regionów"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Więcej niż jeden region na Twojej liście pasuje do kodu transportowego tego pakietu. Aplikacja nie może określić, który został użyty."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Wiele pasujących regionów"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Więcej niż jeden region na Twojej liście pasuje do kodu transportowego tego pakietu. Aplikacja nie może określić, który został użyty.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Czas wysłania: %@"; @@ -1097,7 +1132,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Wszystkie regiony"; +"chats.channelInfo.region.allRegions" = "Bez regionu"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (domyślny)"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 106f6e6ca..f06bf8090 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Tego pliku nie można użyć jako zdjęcia profilowego."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Przesuń i skaluj"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Wybierz"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Kadrowanie zdjęcia profilowego"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Przesuń palcem w górę lub w dół, aby zmienić powiększenie. Użyj czynności, aby przesunąć zdjęcie."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Przesuń w górę"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Przesuń w dół"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Przesuń w lewo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Przesuń w prawo"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Pseudonim"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Ostatnie ogłoszenie"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Ostatnio usłyszany"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Nieprzeczytane wiadomości"; diff --git a/MC1/Resources/Localization/pl.lproj/Localizable.strings b/MC1/Resources/Localization/pl.lproj/Localizable.strings index 78e93be8e..f44806923 100644 --- a/MC1/Resources/Localization/pl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/pl.lproj/Localizable.strings @@ -337,7 +337,7 @@ "error.wifi.connectionFailed" = "Połączenie nie powiodło się: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Upłynął limit czasu połączenia. Sprawdź adres IP i upewnij się, że urządzenie jest w tej samej sieci."; +"error.wifi.connectionTimeout" = "Upłynął limit czasu połączenia. Sprawdź nazwę hosta lub adres IP i upewnij się, że urządzenie jest osiągalne."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Brak połączenia z urządzeniem."; @@ -348,8 +348,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Upłynął limit czasu wysyłania."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Nieprawidłowy adres IP."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nieprawidłowa nazwa hosta lub nieprawidłowy adres IP."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Nieprawidłowy numer portu."; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 136afdf3d..3b39b401c 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Ostatnie ogłoszenie"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Ostatnio usłyszany"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Klucz publiczny"; diff --git a/MC1/Resources/Localization/pl.lproj/Onboarding.strings b/MC1/Resources/Localization/pl.lproj/Onboarding.strings index 5e62b00a8..69674e564 100644 --- a/MC1/Resources/Localization/pl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/pl.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Szczegóły połączenia"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Adres IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nazwa hosta lub adres IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Wyczyść adres IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Wyczyść nazwę hosta lub adres IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Wyczyść port"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Wprowadź adres sieci lokalnej urządzenia MeshCore. Domyślny port to 5000."; +"wifiConnection.connectionDetails.footer" = "Wprowadź nazwę hosta lub adres IP swojego urządzenia MeshCore. Domyślny port to 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Łączenie..."; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 7c24f8269..7edef5441 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -346,15 +346,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Zapisz w przekaźniku, aby zachować zmiany po restartach."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Cały ruch"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Bez regionu"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Cały ruch)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Bez regionu)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Region domowy"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Domyślny zakres"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Brak"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Stosuje zakres do pakietów pochodzących z tego węzła, np. ogłoszeń. To ustawienie jest od razu zapisywane w przekaźniku."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -385,6 +394,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Nie udało się dodać regionu"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Nazwy regionów mogą zawierać tylko litery, cyfry i myślniki."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Nazwy regionów są ograniczone do %d bajtów."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Ten region już istnieje."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Nie udało się usunąć regionu"; diff --git a/MC1/Resources/Localization/pt.lproj/Chats.strings b/MC1/Resources/Localization/pt.lproj/Chats.strings new file mode 100644 index 000000000..64f618c24 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Chats.strings @@ -0,0 +1,1254 @@ +/* + Chats.strings + MC1 + + European Portuguese (pt-PT) translation of messaging UI strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Navigation & Titles + +/* Location: ChatsView.swift - Navigation title for main chat list */ +"chats.title" = "Conversas"; + +/* Location: ChatsView.swift - Search placeholder */ +"chats.search.placeholder" = "Pesquisar conversas"; + +/* Location: ChatsView.swift - Filter option for all conversations */ +"chats.filter.all" = "Todas"; + +/* Location: ChatsView.swift - Filter menu title */ +"chats.filter.title" = "Filtrar"; + +/* Location: ChatsView.swift - Button to clear active filter */ +"chats.filter.clear" = "Limpar filtro"; + +/* Location: ChatsView.swift - Filter option for unread conversations */ +"chats.filter.unread" = "Por ler"; + +/* Location: ChatsView.swift - Filter option for direct messages */ +"chats.filter.directMessages" = "Mensagens diretas"; + +/* Location: ChatsView.swift - Filter option for channels */ +"chats.filter.channels" = "Canais"; + +/* Location: ChatsView.swift - Filter option for rooms */ +"chats.filter.rooms" = "Salas"; + +// MARK: - Compose Menu + +/* Location: ChatsView.swift - Button to start a new direct chat */ +"chats.compose.newChat" = "Nova conversa"; + +/* Location: ChatsView.swift - Button to create or join a channel */ +"chats.compose.newChannel" = "Novo canal"; + +/* Location: ChatsView.swift - Menu label for new message options */ +"chats.compose.newMessage" = "Nova mensagem"; + +// MARK: - Empty States + +/* Location: ChatsView.swift - Title when no conversations exist */ +"chats.emptyState.noConversations.title" = "Nenhuma conversa"; + +/* Location: ChatsView.swift - Description when no conversations exist */ +"chats.emptyState.noConversations.description" = "Inicie uma conversa a partir do separador Nós"; + +/* Location: ChatsView.swift - Title when no unread messages */ +"chats.emptyState.noUnread.title" = "Sem mensagens por ler"; + +/* Location: ChatsView.swift - Description when no unread messages */ +"chats.emptyState.noUnread.description" = "Tudo lido"; + +/* Location: ChatsView.swift - Title when no direct messages */ +"chats.emptyState.noDirectMessages.title" = "Sem mensagens diretas"; + +/* Location: ChatsView.swift - Description when no direct messages */ +"chats.emptyState.noDirectMessages.description" = "Inicie uma conversa a partir do separador Nós"; + +/* Location: ChatsView.swift - Title when no channels */ +"chats.emptyState.noChannels.title" = "Nenhum canal"; + +/* Location: ChatsView.swift - Description when no channels */ +"chats.emptyState.noChannels.description" = "Adira ou crie um canal"; + +/* Location: ChatsView.swift - Title when no rooms exist */ +"chats.emptyState.noRooms.title" = "Nenhuma sala"; + +/* Location: ChatsView.swift - Description when no rooms exist */ +"chats.emptyState.noRooms.description" = "Entre numa sala a partir do separador Nós"; + +/* Location: ChatsView.swift - Split view placeholder when no conversation selected */ +"chats.emptyState.selectConversation" = "Selecione uma conversa"; + +// MARK: - Refresh & Offline Alerts + +/* Location: ChatsView.swift - VoiceOver announcement when viewing cached data offline */ +"chats.accessibility.offlineAnnouncement" = "A visualizar dados em cache. Ligue o dispositivo para atualizar."; + +// MARK: - Room Deletion + +/* Location: ChatsView.swift - Alert title for leaving a room */ +"chats.alert.leaveRoom.title" = "Sair da sala"; + +/* Location: ChatsView.swift - Button to confirm leaving a room */ +"chats.alert.leaveRoom.confirm" = "Sair"; + +/* Location: ChatsView.swift - Alert message explaining what leaving a room does */ +"chats.alert.leaveRoom.message" = "Esta ação remove a sala da lista de chats, elimina todas as mensagens da sala e remove o contacto associado."; + +// MARK: - Message Timestamps + +/* Location: MessageDayDividerView.swift - Label for today's date in the chat day separator */ +/* Location: MessageDayDividerView.swift - Label for yesterday's date in the chat day separator */ +/* Location: RelativeTimestampText.swift - Timestamp for messages under 1 minute old */ +"chats.timestamp.now" = "Agora"; + +// MARK: - Scroll Buttons + +/* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ +"chats.scrollButton.scrollToBottom.accessibilityLabel" = "Ir para a mensagem mais recente"; + +/* Location: ScrollToBottomButton.swift - Accessibility value for unread message count - %d is the number of unread messages */ +"chats.scrollButton.scrollToBottom.accessibilityValue" = "%d mensagens por ler"; +/* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ +"chats.scrollButton.badge.overflow" = "99+"; + +// MARK: - Chat View (Direct Messages) + +/* Location: ChatConversationView.swift - Alert title when message send fails */ +"chats.alert.unableToSend.title" = "Não foi possível enviar"; + +/* Location: ChatConversationView.swift - Alert message when message send fails */ +"chats.alert.unableToSend.message" = "Certifique-se de que há uma ligação ao dispositivo e tente novamente."; + +/* Location: ChatConversationView.swift - Connection status for flood routed contacts */ +"chats.connectionStatus.floodRouting" = "Encaminhamento por difusão"; + +/* Location: ChatConversationView.swift - Connection status format for direct path - %d is hop count */ +"chats.connectionStatus.direct" = "Direto • %d saltos"; + +/* Location: ChatConversationView.swift - Placeholder when message data is unavailable */ +"chats.message.unavailable" = "Mensagem indisponível"; + +/* Location: ChatConversationView.swift - Accessibility label for unavailable message */ +"chats.message.unavailableAccessibility" = "Não foi possível carregar a mensagem"; + +/* Location: ChatConversationView.swift - Empty state text prompting user to start chatting */ +"chats.emptyState.startConversation" = "Inicie uma conversa"; + +/* Location: ChatConversationView.swift - Label showing contact has location */ +"chats.contactInfo.hasLocation" = "Tem localização"; + +/* Location: ChatConversationView.swift - Input bar placeholder for direct messages */ +"chats.input.placeholder.directMessage" = "Mensagem direta"; + +// MARK: - Channel Chat View + +/* Location: ChatConversationView.swift - Fallback channel name format - %d is channel index */ +"chats.channel.defaultName" = "Canal %d"; + +/* Location: ChatConversationView.swift - Header subtitle for public channels */ +"chats.channel.typePublic" = "Canal público"; + +/* Location: ChatConversationView.swift - Header subtitle for private channels */ +"chats.channel.typePrivate" = "Canal privado"; + +/* Location: ChatConversationType.swift - Channel header subtitle when a flood region is active - %@ is the region name */ +"chats.channel.headerRegion" = "Região: %@"; + +/* Location: ChatConversationView.swift - Empty state message */ +"chats.channel.emptyState.noMessages" = "Ainda sem mensagens"; + +/* Location: ChatConversationView.swift - Empty state description for public channel */ +"chats.channel.emptyState.publicDescription" = "Este é um canal público de transmissão"; + +/* Location: ChatConversationView.swift - Empty state description for private channel */ +"chats.channel.emptyState.privateDescription" = "Este é um canal privado"; + +// MARK: - Channel Info Sheet + +/* Location: ChannelInfoSheet.swift - Navigation title */ +/* Location: ChannelInfoSheet.swift - Confirmation dialog title */ +"chats.channelInfo.deleteConfirm.title" = "Eliminar canal"; + +/* Location: ChannelInfoSheet.swift - Confirmation dialog message */ +"chats.channelInfo.deleteConfirm.message" = "Esta ação remove o canal do dispositivo e elimina todas as mensagens locais. Esta ação não pode ser anulada."; + +/* Location: ChannelInfoSheet.swift - Channel type label for public channel */ +"chats.channelInfo.channelType.public" = "Canal público"; + +/* Location: ChannelInfoSheet.swift - Channel type label for hashtag channel */ +"chats.channelInfo.channelType.hashtag" = "Canal hashtag"; + +/* Location: ChannelInfoSheet.swift - Channel type label for private channel */ +"chats.channelInfo.channelType.private" = "Canal privado"; + +/* Location: ChannelInfoSheet.swift - Label for channel slot */ +/* Location: ChannelInfoSheet.swift - Label for last message date */ +/* Location: ChannelInfoSheet.swift - QR code instruction text */ +"chats.channelInfo.scanToJoin" = "Leia o código QR para aderir a este canal"; + +/* Location: ChannelInfoSheet.swift - Section header for QR sharing */ +"chats.channelInfo.shareChannel" = "Partilhar canal"; + +/* Location: ChannelInfoSheet.swift - Label for secret key */ +"chats.channelInfo.secretKey" = "Chave secreta"; + +/* Location: ChannelInfoSheet.swift - Button to copy secret key */ +"chats.channelInfo.copy" = "Copiar"; + +/* Location: ChannelInfoSheet.swift - Section header for manual sharing */ +"chats.channelInfo.manualSharing" = "Partilha manual"; + +/* Location: ChannelInfoSheet.swift - Footer explaining manual sharing */ +"chats.channelInfo.manualSharingFooter" = "Partilhe o nome do canal e esta chave secreta para que outros adiram manualmente."; + +/* Location: ChannelInfoSheet.swift - Clear messages button */ +"chats.channelInfo.clearMessagesButton" = "Limpar mensagens"; + +/* Location: ChannelInfoSheet.swift - Clear messages confirmation dialog title */ +"chats.channelInfo.clearMessagesConfirm.title" = "Limpar mensagens?"; + +/* Location: ChannelInfoSheet.swift - Clear messages confirmation dialog message */ +"chats.channelInfo.clearMessagesConfirm.message" = "Todas as mensagens deste canal serão eliminadas permanentemente. O canal permanece ativo."; + +/* Location: ChannelInfoSheet.swift - Delete channel button */ +"chats.channelInfo.deleteButton" = "Eliminar canal"; + +/* Location: ChannelInfoSheet.swift - Footer explaining delete action */ +"chats.channelInfo.deleteFooter" = "Eliminar remove este canal do dispositivo. É possível aderir novamente mais tarde se tiver a chave secreta."; + +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Falha ao eliminar o canal"; + +/* Location: ChannelInfoSheet.swift - Error when device not connected */ +"chats.error.noDeviceConnected" = "Nenhum dispositivo ligado"; + +/* Location: ChannelInfoSheet.swift - Error when services unavailable */ +"chats.error.servicesUnavailable" = "Serviços indisponíveis"; + +/* Location: ConversationActionError.swift - Error when the radio is disconnected and a conversation delete is attempted */ +"chats.error.notConnectedToDelete" = "Ligue o rádio para eliminar esta conversa."; + +/* Location: ChatConversationView.swift - Error when loading older messages fails */ +"chats.error.loadOlderMessagesFailed" = "Falha ao carregar mensagens anteriores"; + +/* Location: ErrorBannerModifier.swift - Accessibility hint announcing that tapping the banner dismisses it */ +"chats.error.banner.dismissAccessibilityHint" = "Toque para fechar"; + +/* Location: ChatViewModel - Error when persisting a queued send fails (SwiftData write error) */ +"chats.error.sendQueuePersistFailed" = "Não foi possível colocar a mensagem na fila. Tente novamente."; + +/* Location: ChatViewModel+Messages.swift - Error banner when fetching the conversation list fails */ +"chats.error.loadConversationsFailed" = "Não foi possível carregar as conversas."; + +// MARK: - Channel Options Sheet + +/* Location: ChannelOptionsSheet.swift - Loading indicator text */ +"chats.channelOptions.loading" = "A carregar canais..."; + +/* Location: ChannelOptionsSheet.swift - Navigation title */ +"chats.channelOptions.title" = "Novo canal"; + +/* Location: ChannelOptionsSheet.swift - Create private channel option title */ +"chats.channelOptions.createPrivate.title" = "Criar um canal privado"; + +/* Location: ChannelOptionsSheet.swift - Create private channel option description */ +"chats.channelOptions.createPrivate.description" = "Gerar uma chave secreta e um código QR para partilhar"; + +/* Location: ChannelOptionsSheet.swift - Join private channel option title */ +"chats.channelOptions.joinPrivate.title" = "Aderir a um canal privado"; + +/* Location: ChannelOptionsSheet.swift - Join private channel option description */ +"chats.channelOptions.joinPrivate.description" = "Introduza o nome do canal e a chave secreta"; + +/* Location: ChannelOptionsSheet.swift - Scan QR code option title */ +"chats.channelOptions.scanQR.title" = "Ler um código QR"; + +/* Location: ChannelOptionsSheet.swift - Scan QR code option description */ +"chats.channelOptions.scanQR.description" = "Aderir a um canal lendo o código QR"; + +/* Location: ChannelOptionsSheet.swift - Section header for private channels */ +"chats.channelOptions.section.private" = "Canais privados"; + +/* Location: ChannelOptionsSheet.swift - Join public channel option title */ +"chats.channelOptions.joinPublic.title" = "Aderir ao canal público"; + +/* Location: ChannelOptionsSheet.swift - Join public channel option description */ +"chats.channelOptions.joinPublic.description" = "O canal público predefinido"; + +/* Location: ChannelOptionsSheet.swift - Join hashtag channel option title */ +"chats.channelOptions.joinHashtag.title" = "Aderir a um canal hashtag"; + +/* Location: ChannelOptionsSheet.swift - Join hashtag channel option description */ +"chats.channelOptions.joinHashtag.description" = "Canal público ao qual qualquer pessoa pode aderir pelo nome"; + +/* Location: ChannelOptionsSheet.swift - Section header for public channels */ +"chats.channelOptions.section.public" = "Canais públicos"; + +/* Location: ChannelOptionsSheet.swift - Footer when all slots are in use */ +"chats.channelOptions.footer.noSlots" = "Todos os slots de canal estão em utilização. Elimine um canal existente para adicionar um novo."; + +/* Location: ChannelOptionsSheet.swift - Footer when public channel already exists */ +"chats.channelOptions.footer.hasPublic" = "O canal público já está configurado no slot 0."; + +// MARK: - New Chat View + +/* Location: NewChatView.swift - Empty state title */ +"chats.newChat.emptyState.title" = "Nenhum contacto"; + +/* Location: NewChatView.swift - Empty state description */ +"chats.newChat.emptyState.description" = "Os contactos aparecem quando forem descobertos"; + +/* Location: NewChatView.swift - Navigation title */ +"chats.newChat.title" = "Nova conversa"; + +/* Location: NewChatView.swift - Search placeholder */ +"chats.newChat.search.placeholder" = "Pesquisar contactos"; + +/* Location: NewChatView.swift - Contact type label for direct contacts */ +"chats.newChat.contactType.direct" = "Direto"; + +/* Location: NewChatView.swift - Contact type label for repeaters */ +"chats.newChat.contactType.repeater" = "Repetidor"; + +/* Location: NewChatView.swift - Contact type label for rooms */ +// MARK: - Conversation Row + +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for favorite indicator */ +"chats.row.favorite" = "Favorito"; + +/* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ +"chats.row.noMessages" = "Ainda sem mensagens"; + +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Falha ao enviar"; + +/* Location: MutedIndicator.swift - Accessibility label for muted indicator */ +"chats.row.muted" = "Silenciado"; + +/* Location: MutedIndicator.swift - Accessibility label for mentions-only indicator */ +"chats.row.mentionsOnly" = "Só menções"; + +// MARK: - Notification Level Picker + +/* Location: NotificationLevelPicker.swift - Accessibility label for notification level picker */ +"chats.notificationLevel.label" = "Nível de notificação"; + +/* Location: NotificationLevelPicker.swift - Accessibility hint for notification level picker */ +"chats.notificationLevel.hint" = "Escolha quando receber notificações"; + +/* Location: NotificationLevelPicker.swift - Muted level label */ +"chats.notificationLevel.muted" = "Silenciado"; + +/* Location: NotificationLevelPicker.swift - Mentions-only level label */ +"chats.notificationLevel.mentions" = "Menções"; + +/* Location: NotificationLevelPicker.swift - All notifications level label */ +"chats.notificationLevel.all" = "Todas"; + +/* Location: NotificationLevelPicker.swift - Accessibility description for muted level */ +"chats.notificationLevel.accessibility.muted" = "Silenciado, sem notificações"; + +/* Location: NotificationLevelPicker.swift - Accessibility description for mentions-only level */ +"chats.notificationLevel.accessibility.mentionsOnly" = "Só menções"; + +/* Location: NotificationLevelPicker.swift - Accessibility description for all-notifications level */ +"chats.notificationLevel.accessibility.all" = "Todas as notificações"; + +// MARK: - Swipe Actions + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to delete */ +"chats.action.delete" = "Eliminar"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to unmute */ +"chats.action.unmute" = "Reativar som"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to mute */ +"chats.action.mute" = "Silenciar"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to remove from favorites */ +"chats.action.unfavorite" = "Remover dos favoritos"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to add to favorites */ +"chats.action.favorite" = "Favorito"; + +// MARK: - Room Conversation Row + +/* Location: RoomConversationRow.swift - Status when room is connected */ +"chats.room.connected" = "Ligado"; + +/* Location: RoomConversationRow.swift - Prompt to reconnect to room */ +"chats.room.tapToReconnect" = "Toque para voltar a ligar"; + +// MARK: - Room Authentication Sheet + +/* Location: RoomAuthenticationSheet.swift - Error title when room not found */ +"chats.roomAuth.notFound.title" = "Sala não encontrada"; + +/* Location: RoomAuthenticationSheet.swift - Error description when room not found */ +"chats.roomAuth.notFound.description" = "Não foi possível encontrar o contacto da sala"; + +// MARK: - Conversation List Sections + +/* Location: ConversationListContent.swift - Section accessibility label for favorites */ +/* Location: ConversationListContent.swift - Section accessibility label for other conversations */ +// MARK: - Mention Suggestions + +/* Location: MentionSuggestionRow.swift - Accessibility label for mention row - %@ is contact name */ +"chats.mention.accessibility.label" = "Mencionar %@"; + +/* Location: MentionSuggestionRow.swift - Accessibility hint for channel sender mention */ +"chats.mention.accessibility.hintChannel" = "Remetente do canal. Toque duas vezes para mencionar"; + +/* Location: MentionSuggestionRow.swift - Accessibility hint for saved contact mention */ +"chats.mention.accessibility.hintContact" = "Contacto guardado. Toque duas vezes para mencionar"; + +/* Location: MentionSuggestionView.swift - Accessibility label for mention suggestions popup */ +"chats.suggestions.accessibilityLabel" = "Sugestões de menção"; + +// MARK: - Create Private Channel View + +/* Location: CreatePrivateChannelView.swift - Title when sharing created channel */ +"chats.createPrivate.titleShare" = "Partilhar canal privado"; + +/* Location: CreatePrivateChannelView.swift - Title when creating channel */ +"chats.createPrivate.titleCreate" = "Criar canal privado"; + +/* Location: CreatePrivateChannelView.swift - Text field placeholder for channel name */ +"chats.createPrivate.channelName" = "Nome do canal"; + +/* Location: CreatePrivateChannelView.swift - Section header for channel details */ +"chats.createPrivate.section.details" = "Detalhes do canal"; + +/* Location: CreatePrivateChannelView.swift - Section header for generated secret */ +"chats.createPrivate.section.secret" = "Segredo gerado"; + +/* Location: CreatePrivateChannelView.swift - Footer explaining generated secret */ +"chats.createPrivate.secretFooter" = "Foi gerada uma chave secreta aleatória. Poderá partilhá-la através de um código QR depois de criar o canal."; + +/* Location: CreatePrivateChannelView.swift - Button to create channel */ +"chats.createPrivate.createButton" = "Criar canal"; + +/* Location: CreatePrivateChannelView.swift - Section header for manual sharing */ +"chats.createPrivate.section.shareManually" = "Partilhar manualmente"; + +/* Location: CreatePrivateChannelView.swift - Footer explaining manual sharing */ +"chats.createPrivate.shareManuallyFooter" = "Partilhe o nome do canal e esta chave secreta com outras pessoas. São ambos necessários para aderir."; + +// MARK: - Join Private Channel View + +/* Location: JoinPrivateChannelView.swift - Text field placeholder for secret key */ +"chats.joinPrivate.secretKeyPlaceholder" = "Chave secreta (32 caracteres hexadecimais)"; + +/* Location: JoinPrivateChannelView.swift - Validation error for invalid secret */ +"chats.joinPrivate.error.invalidSecret" = "A chave secreta deve ter exatamente 32 caracteres hexadecimais (0-9, A-F)"; + +/* Location: JoinPrivateChannelView.swift - Footer explaining how to join */ +"chats.joinPrivate.footer" = "Introduza o nome do canal e a chave secreta partilhados por quem criou o canal."; + +/* Location: JoinPrivateChannelView.swift - Button to join channel */ +"chats.joinPrivate.joinButton" = "Aderir ao canal"; + +/* Location: JoinPrivateChannelView.swift - Navigation title */ +"chats.joinPrivate.title" = "Aderir a canal privado"; + +/* Location: JoinPrivateChannelView.swift - Error for invalid secret key format */ +"chats.joinPrivate.error.invalidFormat" = "Formato de chave secreta inválido"; + +// MARK: - Join Public Channel View + +/* Location: JoinPublicChannelView.swift - Channel name displayed */ +"chats.joinPublic.channelName" = "Canal público"; + +/* Location: JoinPublicChannelView.swift - Description of public channel */ +"chats.joinPublic.description" = "O canal público é um canal aberto de transmissão no slot 0. Todos os dispositivos na rede mesh podem enviar e receber mensagens neste canal."; + +/* Location: JoinPublicChannelView.swift - Button to add public channel */ +"chats.joinPublic.addButton" = "Adicionar canal público"; + +/* Location: JoinPublicChannelView.swift - Navigation title */ +"chats.joinPublic.title" = "Aderir ao canal público"; + +// MARK: - Join Hashtag Channel View + +/* Location: JoinHashtagChannelView.swift - Text field placeholder */ +"chats.joinHashtag.placeholder" = "nome-do-canal"; + +/* Location: JoinHashtagChannelView.swift - Section header */ +"chats.joinHashtag.section.header" = "Canal hashtag"; + +/* Location: JoinHashtagChannelView.swift - Footer explaining hashtag channels */ +"chats.joinHashtag.footer" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir introduzindo o mesmo nome. Só são permitidas letras minúsculas, números e hífenes."; + +/* Location: JoinHashtagChannelView.swift - Description about encryption */ +"chats.joinHashtag.encryptionDescription" = "O nome do canal é usado para gerar a chave de encriptação. Qualquer pessoa com o mesmo nome pode ler as mensagens."; + +/* Location: JoinHashtagChannelView.swift - Button format for existing channel - %@ is channel name */ +"chats.joinHashtag.goToButton" = "Ir para #%@"; + +/* Location: JoinHashtagChannelView.swift - Button format for new channel - %@ is channel name */ +"chats.joinHashtag.joinButton" = "Aderir a #%@"; + +/* Location: JoinHashtagChannelView.swift - Accessibility hint for existing channel */ +"chats.joinHashtag.existingHint" = "Abre o canal ao qual já aderiu"; + +/* Location: JoinHashtagChannelView.swift - Accessibility hint for new channel */ +"chats.joinHashtag.newHint" = "Cria e adere a este canal hashtag"; + +/* Location: JoinHashtagChannelView.swift - Footer label when channel already joined */ +"chats.joinHashtag.alreadyJoined" = "Já aderiu"; + +/* Location: JoinHashtagChannelView.swift - Accessibility label for already joined */ +"chats.joinHashtag.alreadyJoinedAccessibility" = "Já aderiu a este canal"; + +/* Location: JoinHashtagChannelView.swift - Navigation title */ +"chats.joinHashtag.title" = "Aderir a canal hashtag"; + +// MARK: - Join Hashtag From Message View + +/* Location: JoinHashtagFromMessageView.swift - Loading text */ +"chats.joinFromMessage.loading" = "A carregar..."; + +/* Location: JoinHashtagFromMessageView.swift - Navigation title */ +"chats.joinFromMessage.title" = "Aderir ao canal"; + +/* Location: JoinHashtagFromMessageView.swift - No device connected title */ +"chats.joinFromMessage.noDevice.title" = "Nenhum dispositivo ligado"; + +/* Location: JoinHashtagFromMessageView.swift - No device connected description - %@ is channel name */ +"chats.joinFromMessage.noDevice.description" = "Ligue um dispositivo para aderir a %@."; + +/* Location: JoinHashtagFromMessageView.swift - No slots available title */ +"chats.joinFromMessage.noSlots.title" = "Nenhum slot disponível"; + +/* Location: JoinHashtagFromMessageView.swift - No slots available description - %@ is channel name */ +"chats.joinFromMessage.noSlots.description" = "Todos os slots de canal estão cheios. Remova um canal existente para aderir a %@."; + +/* Location: JoinHashtagFromMessageView.swift - Description of hashtag channels */ +"chats.joinFromMessage.description" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir introduzindo o mesmo nome."; + +/* Location: JoinHashtagFromMessageView.swift - Button to join channel - %@ is channel name */ +"chats.joinFromMessage.joinButton" = "Aderir a %@"; + +/* Location: JoinHashtagFromMessageView.swift - Error for no available slots */ +"chats.joinFromMessage.error.noSlots" = "Nenhum slot disponível."; + +/* Location: JoinHashtagFromMessageView.swift - Error for invalid channel name */ +"chats.joinFromMessage.error.invalidName" = "Formato de nome de canal inválido."; + +/* Location: JoinHashtagFromMessageView.swift - Error when channel created but couldn't be loaded */ +"chats.joinFromMessage.error.loadFailed" = "Canal criado, mas não foi possível carregá-lo."; + +/* Location: JoinChannelConfirmationSheet.swift - Region scope shown before join - %@ is region name */ +"chats.joinFromMessage.regionScope" = "Região: %@"; + +/* Location: JoinChannelConfirmationSheet.swift - Soft warning when hashtag-shaped name uses a non-public secret */ +"chats.joinFromMessage.hashtagSecretMismatch" = "Este segredo não corresponde à chave do hashtag público. Irá aderir a um canal privado com este nome."; + +// MARK: - Scan Channel QR View + +/* Location: ScanChannelQRView.swift - Navigation title */ +"chats.scanQR.title" = "Ler código QR"; + +/* Location: ScanChannelQRView.swift - Error when scanner not available */ +"chats.scanQR.notAvailable.title" = "Scanner indisponível"; + +/* Location: ScanChannelQRView.swift - Error description when scanner not available */ +"chats.scanQR.notAvailable.description" = "A leitura de códigos QR não é suportada neste dispositivo"; + +/* Location: ScanChannelQRView.swift - Instruction to point camera */ +"chats.scanQR.instruction" = "Aponte a câmara para o código QR de um canal"; + +/* Location: ScanChannelQRView.swift - Button to scan again */ +"chats.scanQR.scanAgain" = "Ler novamente"; + +/* Location: ScanChannelQRView.swift - Camera permission denied title */ +"chats.scanQR.permissionDenied.title" = "É necessário acesso à câmara"; + +/* Location: ScanChannelQRView.swift - Camera permission denied message */ +"chats.scanQR.permissionDenied.message" = "Ative o acesso à câmara em Definições para ler códigos QR."; + +/* Location: ScanChannelQRView.swift - Button to open settings */ +"chats.scanQR.openSettings" = "Abrir Definições"; + +/* Location: ScanChannelQRView.swift - Error for invalid QR format */ +"chats.scanQR.error.invalidFormat" = "Formato de código QR inválido"; + +/* Location: ScanChannelQRView.swift - Error for invalid channel data */ +// MARK: - Message Bubble + +/* Location: UnifiedMessageBubble.swift - Fallback sender name */ +"chats.message.sender.unknown" = "Desconhecido"; + +/* Location: UnifiedMessageBubble.swift - Accessibility text for sender names resolved from a short prefix */ +"chats.message.sender.possibleMatch" = "Possível correspondência, identificada pelo prefixo curto"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to reply */ +"chats.message.action.reply" = "Responder"; + +/* Location: MessageActionsSheet.swift - Context menu action to mention */ +"chats.message.action.mention" = "Mencionar"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to copy */ +"chats.message.action.copy" = "Copiar"; + +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traduzir"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Mostrar original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "A traduzir..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Traduzido: %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ +"chats.message.action.repeatDetails" = "Detalhes da retransmissão"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to send again */ +"chats.message.action.sendAgain" = "Enviar novamente"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing heard repeats - %d is count, second %@ is "repeat" or "repeats" */ +"chats.message.info.heardRepeats" = "Receção: %d %@"; + +/* Location: UnifiedMessageBubble.swift - Singular form of repeat */ +"chats.message.repeat.singular" = "retransmissão"; + +/* Location: UnifiedMessageBubble.swift - Plural form of repeats */ +"chats.message.repeat.plural" = "retransmissões"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing sent time - %@ is formatted date */ +"chats.message.info.sent" = "Enviado: %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing round trip time - %d is milliseconds */ +"chats.message.info.roundTrip" = "Ida e volta: %dms"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to view path */ +"chats.message.action.viewPath" = "Ver rota"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing hop count - %@ is count or "Direct" */ +"chats.message.info.hops" = "Saltos: %@"; + +/* Location: ActionsDetailsSection.swift - Info row showing the path hash size in bytes - %d is 1, 2, or 3 */ +"chats.message.info.pathHash" = "Hash da rota: %d byte"; + +/* Location: UnifiedMessageBubble.swift - Indicator that timestamp was adjusted */ +"chats.message.info.adjusted" = "(ajustado)"; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for adjusted timestamp */ +/* Location: UnifiedMessageBubble.swift - Accessibility hint for adjusted timestamp */ +/* Location: ActionsDetailsSection.swift - Info row showing the raw uncorrected wire send time when the timestamp was corrected - %@ is formatted date */ +"chats.message.info.originalSendTime" = "Hora de envio original: %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing received time - %@ is formatted date */ +"chats.message.info.received" = "Recebido: %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing SNR - %@ is formatted value */ +"chats.message.info.snr" = "SNR: %@"; + +/* Location: MessageActionsSheet.swift - Details row showing the radio region a flood-routed message was broadcast under - %@ is region name */ +"chats.message.info.floodedUnder" = "Região: %@"; + +/* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ +"chats.message.info.regionUnresolved" = "Região: Desconhecida"; + +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Região (ambígua): %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu submenu label */ +"chats.message.action.details" = "Detalhes"; + +/* Location: ActionsDetailsSection.swift - Accessibility value when the details row is expanded */ +"chats.message.action.expanded" = "Expandido"; + +/* Location: ActionsDetailsSection.swift - Accessibility value when the details row is collapsed */ +"chats.message.action.collapsed" = "Recolhido"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to delete */ +"chats.message.action.delete" = "Eliminar"; + +/* Location: MessageActionsSheet.swift - Purpose: Block sender action */ +"chats.message.action.blockSender" = "Bloquear remetente"; + +/* Location: MessageActionsSheet.swift - Purpose: Action to start a DM with the channel sender */ +"chats.message.action.sendDM" = "Enviar mensagem direta"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to retry a failed send */ +"chats.message.action.retry" = "Tentar novamente"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to open the reactions detail sheet */ +"chats.message.action.viewReactions" = "Ver reações"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to open the message's link preview URL */ +"chats.message.action.openLink" = "Abrir ligação"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a web link, %@ is the host */ +"chats.message.action.openWebLink" = "Abrir ligação: %@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a shared map coordinate */ +"chats.message.action.openMapLink" = "Abrir mapa"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a mention, %@ is the mentioned name */ +"chats.message.action.openMention" = "Menção: %@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a hashtag channel, %@ is the channel name */ +"chats.message.action.openHashtag" = "Abrir #%@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to add a shared contact, %@ is the contact name */ +"chats.message.action.addContact" = "Adicionar contacto: %@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a shared channel link, %@ is the channel name */ +"chats.message.action.openChannel" = "Abrir canal: %@"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to open an attached image full-screen */ +"chats.message.action.viewImage" = "Ver imagem"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to retry a failed image download */ +"chats.message.action.retryImage" = "Tentar imagem novamente"; + +/* Location: UnifiedMessageBubble.swift - Status row retry button */ +"chats.message.status.retry" = "Tentar novamente"; + +/* Location: UnifiedMessageBubble.swift - Message status sending */ +"chats.message.status.sending" = "A enviar..."; + +/* Location: UnifiedMessageBubble.swift - Message status sent */ +"chats.message.status.sent" = "Enviado"; + +/* Location: UnifiedMessageBubble.swift - Message status sent multiple times - %d is count */ +"chats.message.status.sentMultiple" = "Enviado %d vezes"; + +/* Location: UnifiedMessageBubble.swift - Message status delivered */ +"chats.message.status.delivered" = "Entregue"; + +/* Location: UnifiedMessageBubble.swift - Message status failed */ +"chats.message.status.failed" = "Falhou"; + +/* Location: UnifiedMessageBubble.swift - Message status retrying */ +"chats.message.status.retrying" = "A tentar novamente..."; + +/* Location: UnifiedMessageBubble.swift - Message status retrying with attempt count - %d is current attempt, %d is max attempts */ +"chats.message.status.retryingAttempt" = "A tentar %d/%d"; + +/* Location: UnifiedMessageBubble.swift - SNR quality excellent */ +"chats.signal.excellent" = "Excelente"; + +/* Location: UnifiedMessageBubble.swift - SNR quality good */ +"chats.signal.good" = "Bom"; + +/* Location: UnifiedMessageBubble.swift - SNR quality fair */ +"chats.signal.fair" = "Razoável"; + +/* Location: UnifiedMessageBubble.swift - SNR quality poor */ +"chats.signal.poor" = "Fraco"; + +/* Location: UnifiedMessageBubble.swift - SNR quality very poor */ +/* Location: UnifiedMessageBubble.swift - Hop count direct */ +"chats.message.hops.direct" = "Direto"; + +/* Location: UnifiedMessageBubble.swift - Path footer for direct messages (no hops) */ +"chats.message.path.direct" = "Direto"; +/* Routing strategy where the radio rebroadcasts the message to every neighbor (no specific path). UI displays this as a label in the per-message details sheet. Prefer a localized term over the English loanword if one exists in your locale's networking terminology. */ +"chats.message.path.flood" = "Difusão"; + +/* Location: UnifiedMessageBubble.swift - Fallback path showing hop count - %d is number */ +/* Location: MessagePathFormatter.swift - Fallback when path nodes unavailable */ +/* Location: UnifiedMessageBubble.swift - Accessibility label for routing path - %@ is the path */ +"chats.message.path.accessibilityLabel" = "Rota de encaminhamento: %@"; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for hop count display - %d is count */ +"chats.message.hopCount.accessibilityLabel" = "Número de saltos: %d"; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name */ +"chats.message.region.accessibilityLabel" = "Região %@"; + +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Região, ambígua: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Várias regiões correspondentes"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Mais do que uma região da lista corresponde ao código de transporte deste pacote. A app não consegue identificar qual foi usada."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Várias regiões correspondentes"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Mais do que uma região da lista corresponde ao código de transporte deste pacote. A app não consegue identificar qual foi usada.%@"; + +/* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ +"chats.message.sendTime.accessibilityLabel" = "Hora de envio: %@"; + +/* Location: BubbleFooterRow.swift - Accessibility label for the send time footer when the sender's clock was corrected - %@ is the time */ +"chats.message.sendTime.correctedAccessibilityLabel" = "Hora de envio: %@, ajustada devido a um erro no relógio do remetente"; + +// MARK: - Reactions + +/* Location: ReactionBadgesView.swift - Accessibility label for reaction badge - %@ is emoji, %d is count */ +"reactions.badge" = "%@ %d"; + +/* Location: ReactionBadgesView.swift - Accessibility hint for reaction badge */ +"reactions.badge_hint" = "Toque duas vezes para adicionar uma reação, toque sem soltar para ver detalhes"; + +/* Location: ReactionBadgesView.swift - Accessibility label for overflow badge - %d is count */ +"reactions.more_badge" = "%d tipos de reação adicionais"; + +/* Location: ReactionBadgesView.swift - Accessibility hint for overflow badge */ +"reactions.more_badge_hint" = "Toque duas vezes para ver todas as reações"; + +/* Location: EmojiPickerRow.swift - Label for more emojis button */ +"reactions.more_emojis" = "Mais emojis"; + +/* Location: ReactionBadgesView.swift - VoiceOver accessibility action to view reaction details */ +"reactions.view_details" = "Ver detalhes das reações"; + +/* Location: ReactionDetailsSheet.swift - Navigation title */ +/* Location: ChatViewModel.swift - Error message when reaction fails to send */ +/* Location: MessageContextOverlay.swift - Reply action label */ +/* Location: MessageContextOverlay.swift - Copy action label */ +/* Location: MessageContextOverlay.swift - Delete action label */ +// MARK: - Message Actions Sheet + +/* Location: MessageActionsSheet.swift - Sheet title */ +// MARK: - Chat Input Bar + +/* Location: ChatInputBar.swift - Accessibility label for text input */ +"chats.input.accessibilityLabel" = "Campo de mensagem"; + +/* Location: ChatInputBar.swift - Accessibility hint for text input */ +"chats.input.accessibilityHint" = "Introduza a mensagem aqui"; + +/* Location: ChatInputBar.swift - Accessibility label for character count - %d is current, %d is max */ +"chats.input.characterCount" = "%d de %d caracteres"; + +/* Location: ChatInputBar.swift - Accessibility label when message too long */ +"chats.input.tooLong" = "Mensagem demasiado longa"; + +/* Location: ChatInputBar.swift - Accessibility label for send button */ +"chats.input.sendMessage" = "Enviar mensagem"; + +/* Location: ChatInputBar.swift - Accessibility hint when over character limit - %d is characters to remove */ +"chats.input.removeCharacters" = "Remova %d caracteres para enviar"; + +/* Location: ChatInputBar.swift - Accessibility hint when not connected */ +"chats.input.requiresConnection" = "Requer ligação ao rádio"; + +/* Location: ChatInputBar.swift - Accessibility hint when ready to send */ +"chats.input.tapToSend" = "Toque para enviar a mensagem"; + +/* Location: ChatInputBar.swift - Accessibility hint when message is empty */ +"chats.input.typeFirst" = "Introduza primeiro uma mensagem"; + +/* Location: ChatInputBar.swift - Accessibility label for encrypted indicator */ +"chats.input.encrypted" = "Encriptado"; + +/* Location: ChatInputBar.swift - Accessibility label for not encrypted indicator */ +"chats.input.notEncrypted" = "Não encriptado"; + +/* Location: ChatShareMenu.swift - Accessibility label for the share (plus) button */ +"chats.input.shareButton.accessibilityLabel" = "Partilhar"; + +// MARK: - Share Menu + +/* Location: ChatShareMenu.swift - Share menu action to share the current location */ +"chats.share.location" = "Partilhar localização"; + +/* Location: ChatShareMenu.swift - Share menu action to share a contact */ +"chats.share.contact" = "Partilhar contacto"; + +/* Location: ChatShareMenu.swift - Share menu action to share the user's own node info */ +"chats.share.myInfo" = "Partilhar os meus dados"; + +// MARK: - Contact Picker + +/* Location: ShareContactPickerSheet.swift - Navigation title for the share-contact picker */ +"chats.contactPicker.title" = "Partilhar contacto"; + +/* Location: ShareContactPickerSheet.swift - Search placeholder for the contact picker */ +"chats.contactPicker.search.placeholder" = "Pesquisar contactos"; + +/* Location: ShareContactPickerSheet.swift - Empty state when no contacts are available to share */ +"chats.contactPicker.emptyState" = "Nenhum contacto para partilhar"; + +// MARK: - Message Path Sheet + +/* Location: MessagePathSheet.swift - Empty state title */ +"chats.path.unavailable.title" = "Rota indisponível"; + +/* Location: MessagePathSheet.swift - Empty state description */ +"chats.path.unavailable.description" = "Os dados da rota não estão disponíveis para esta mensagem"; + +/* Location: MessagePathSheet.swift - Button to copy path */ +"chats.path.copyButton" = "Copiar rota"; + +/* Location: MessagePathSheet.swift - Accessibility label for copy button */ +"chats.path.copyAccessibility" = "Copiar rota para a área de transferência"; + +/* Location: MessagePathSheet.swift - Accessibility hint for copy button */ +"chats.path.copyHint" = "Copia os IDs dos nós como valores hexadecimais"; + +/* Location: MessagePathSheet.swift - Section header for path */ +/* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ +"chats.path.map" = "Mapa da rota"; + +/* Location: MessagePathMapView.swift - Center on path map control accessibility label */ +"chats.path.centerOnPath" = "Centrar na rota"; + +// MARK: - Path Hop Row View + +/* Location: PathHopRowView.swift - Unknown node name */ +"chats.path.hop.unknown" = ""; + +/* Location: PathHopRowView.swift - Accessibility text for path hop names resolved from a short prefix */ +"chats.path.hop.possibleMatch" = "Possível correspondência, identificada pelo prefixo curto"; + +/* Location: FallbackMatchIndicatorView.swift - Title for possible match explanation popover */ +"chats.path.hop.possibleMatchTitle" = "Possível correspondência"; + +/* Location: FallbackMatchIndicatorView.swift - Explanation of what a possible match means */ +"chats.path.hop.possibleMatchExplanation" = "Vários nós partilham este prefixo. O nome apresentado pode não estar correto."; + +/* Location: PathHopRowView.swift - Label for sender (first hop) */ +"chats.path.hop.sender" = "Remetente"; + +/* Location: PathHopRowView.swift - Label for intermediate hops - %d is hop number */ +"chats.path.hop.number" = "Salto %d"; + +/* Location: PathHopRowView.swift - Accessibility value format for last hop - %@ is quality, %@ is SNR */ +"chats.path.hop.signalQuality" = "Sinal: %@, SNR %@ dB"; + +/* Location: PathHopRowView.swift - Accessibility value format for non-last hops - %@ is hex ID */ +"chats.path.hop.nodeId" = "ID do nó: %@"; + +/* Location: PathHopRowView.swift - Unknown signal quality */ +"chats.path.hop.signalUnknown" = "Desconhecido"; + +/* Location: PathHopRowView.swift - Label for receiver (your device) */ +"chats.path.receiver.label" = "Destinatário"; + +/* Location: MessagePathSheet.swift - Fallback name when device name unavailable */ +"chats.path.receiver.you" = "Eu"; + +// MARK: - Repeat Details Sheet + +/* Location: RepeatDetailsSheet.swift - Empty state title */ +"chats.repeats.emptyState.title" = "Ainda sem retransmissões"; + +/* Location: RepeatDetailsSheet.swift - Empty state description */ +"chats.repeats.emptyState.description" = "As retransmissões aparecem aqui à medida que a mensagem se propaga pela mesh"; + +/* Location: RepeatDetailsSheet.swift - Navigation title */ +// MARK: - Repeat Row View + +/* Location: RepeatRowView.swift - Accessibility label format - %@ is repeater name */ +"chats.repeats.row.accessibility" = "Retransmissão de %@"; + +/* Location: RepeatRowView.swift - Accessibility value format - %@ is quality, %@ is SNR, %@ is RSSI */ +"chats.repeats.row.accessibilityValue" = "Sinal %@, SNR %@, RSSI %@"; + +/* Location: RepeatRowView.swift - Singular hop label */ +"chats.repeats.hop.singular" = "1 salto"; + +/* Location: RepeatRowView.swift - Plural hops label - %d is count */ +"chats.repeats.hop.plural" = "%d saltos"; + +/* Location: RepeatRowView.swift - Unknown repeater name */ +"chats.repeats.unknownRepeater" = ""; + +// MARK: - Link Preview Card + +/* Location: LinkPreviewCard.swift - Accessibility label for link preview - %1$@ is title, %2$@ is domain */ +"chats.linkPreview.accessibility.label" = "%1$@, de %2$@, ligação"; + +/* Location: LinkPreviewCard.swift - Accessibility hint for link preview */ +"chats.linkPreview.accessibility.hint" = "Abre no navegador"; + +// MARK: - Tap to Load Preview + +/* Location: TapToLoadPreview.swift - Loading state text */ +"chats.preview.loading" = "A carregar pré-visualização..."; + +/* Location: TapToLoadPreview.swift - Idle state text */ +"chats.preview.tapToLoad" = "Toque para carregar a pré-visualização"; + +/* Location: TapToLoadPreview.swift - Loading accessibility label format - %@ is host */ +"chats.preview.loadingAccessibility" = "A carregar pré-visualização de %@"; + +/* Location: TapToLoadPreview.swift - Idle accessibility label format - %@ is host */ +"chats.preview.tapAccessibility" = "Carregar pré-visualização de %@"; + +/* Location: TapToLoadPreview.swift - Loading accessibility hint */ +"chats.preview.loadingHint" = "Aguarde"; + +/* Location: TapToLoadPreview.swift - Idle accessibility hint */ +"chats.preview.tapHint" = "Obtém o título e a imagem do sítio web"; + +// MARK: - Inline Image + +/* Location: InlineImageView.swift - Accessibility label for static image */ +"chats.inlineImage.imageAccessibility" = "Imagem"; + +/* Location: InlineImageFragmentView.swift - Accessibility label for loading state */ +/* Location: InlineImageView.swift - Accessibility label for animated image */ +"chats.inlineImage.animatedAccessibility" = "Imagem animada"; + +/* Location: InlineImageView.swift - Accessibility hint for tap to view full screen */ +"chats.inlineImage.tapHint" = "Toque duas vezes para ver em ecrã inteiro"; + +/* Location: UnifiedMessageBubble.swift - Tap to retry loading failed image */ +"chats.inlineImage.tapToRetry" = "Toque para tentar novamente"; + +/* Location: UnifiedMessageBubble.swift - Accessibility hint for retry button */ +"chats.inlineImage.retryHint" = "Toque duas vezes para tentar carregar a imagem novamente"; + +/* Location: InlineImageFragmentView.swift - VoiceOver identity for a failed inline image */ +"chats.inlineImage.failedLabel" = "Falha ao carregar a imagem"; + +// MARK: - Image Viewer + +/* Location: FullScreenImageViewer.swift - Close button label */ +"chats.imageViewer.close" = "Fechar"; + +/* Location: FullScreenImageViewer.swift - Share button label */ +"chats.imageViewer.share" = "Imagem"; + +// MARK: - Common Buttons (using Localizable.strings common keys where possible) + +/* Location: Various - OK button (use L10n.Localizable.Common.ok) */ +"chats.common.ok" = "OK"; + +/* Location: Various - Cancel button (use L10n.Localizable.Common.cancel) */ +"chats.common.cancel" = "Cancelar"; + +/* Location: Various - Done button (use L10n.Localizable.Common.done) */ +"chats.common.done" = "Concluído"; + +// MARK: - Reaction Details Sheet + +/* Location: ReactionDetailsSheet.swift - Navigation title */ +"reactions.title" = "Reações"; + +/* Location: ReactionDetailsSheet.swift - Empty state title */ +"reactions.emptyState.title" = "Nenhuma reação"; + +/* Location: ReactionDetailsSheet.swift - Empty state description */ +"reactions.emptyState.description" = "Ainda ninguém reagiu a esta mensagem"; + +// MARK: - Emoji Picker Categories + +/* Location: EmojiProvider.swift - Frequently used category */ +"reactions.emoji.category.frequent" = "Utilizados com frequência"; + +/* Location: EmojiProvider.swift - People category */ +"reactions.emoji.category.people" = "Smileys e pessoas"; + +/* Location: EmojiProvider.swift - Nature category */ +"reactions.emoji.category.nature" = "Animais e natureza"; + +/* Location: EmojiProvider.swift - Foods category */ +"reactions.emoji.category.foods" = "Comida e bebida"; + +/* Location: EmojiProvider.swift - Activity category */ +"reactions.emoji.category.activity" = "Atividade"; + +/* Location: EmojiProvider.swift - Places category */ +"reactions.emoji.category.places" = "Viagens e locais"; + +/* Location: EmojiProvider.swift - Objects category */ +"reactions.emoji.category.objects" = "Objetos"; + +/* Location: EmojiProvider.swift - Symbols category */ +"reactions.emoji.category.symbols" = "Símbolos"; + +/* Location: EmojiProvider.swift - Flags category */ +"reactions.emoji.category.flags" = "Bandeiras"; + +/* Location: EmojiPickerSheet.swift - Search placeholder */ +"reactions.emoji.searchPlaceholder" = "Pesquisar emojis"; + +// MARK: - Divider + +/* Location: NewMessagesDividerView.swift - Label for new messages divider */ +"chats.divider.newMessages" = "Novas mensagens"; + +/* Location: NewMessagesDividerView.swift - VoiceOver label for new messages divider */ +"chats.divider.newMessagesAccessibility" = "Separador de mensagens novas"; + +// MARK: - Tips + +/* Location: DeviceMenuTip.swift - Purpose: Tip title introducing device menu */ +"chats.tip.deviceMenu.title" = "Menu do dispositivo"; + +/* Location: DeviceMenuTip.swift - Purpose: Tip message explaining device menu features */ +"chats.tip.deviceMenu.message" = "Faça a gestão da ligação, envie Adverts e consulte a bateria. O rádio permanece ligado mesmo depois de forçar o encerramento da app. Toque em Desligar para interromper a ligação."; + +// MARK: - Block Sender Sheet + +/* Location: BlockSenderSheet.swift - Purpose: Sheet title with sender name */ +"chats.blockSender.title" = "Bloquear \"%@\""; + +/* Location: BlockSenderSheet.swift - Purpose: Explanation of name-based blocking limitation */ +"chats.blockSender.limitation" = "As mensagens de canal não incluem a identidade do remetente. Este bloqueio baseia-se apenas no nome, que o remetente pode alterar para o contornar."; + +/* Location: BlockSenderSheet.swift - Purpose: Section header when matching contacts found */ +"chats.blockSender.matchingContacts" = "Os contactos seguintes partilham este nome. Selecione os que também devem ser bloqueados:"; + +/* Location: BlockSenderSheet.swift - Purpose: Cancel button */ +"chats.blockSender.cancel" = "Cancelar"; + +/* Location: BlockSenderSheet.swift - Purpose: Destructive block button */ +"chats.blockSender.blockAnyway" = "Bloquear"; + +// MARK: - Contact Match Row + +/* Location: ContactMatchRow.swift - Purpose: Accessibility value when contact is selected */ +"chats.contactMatch.accessibility.selected" = "Selecionado"; + +/* Location: ContactMatchRow.swift - Purpose: Accessibility value when contact is not selected */ +"chats.contactMatch.accessibility.notSelected" = "Não selecionado"; + +/* Location: ContactMatchRow.swift - Purpose: Public key label */ +"chats.contactMatch.key" = "Chave: %@"; + +// MARK: - Send DM Sheet + +/* Location: SendDMSheet.swift - Purpose: Sheet title with sender name */ +"chats.sendDM.title" = "Enviar mensagem direta a \"%@\""; + +/* Location: SendDMSheet.swift - Purpose: Name-based matching limitation warning */ +"chats.sendDM.limitation" = "As mensagens de canal não incluem a identidade do remetente. O contacto é identificado apenas pelo nome, por isso pode ser outra pessoa com o mesmo nome."; + +/* Location: SendDMSheet.swift - Purpose: Section header listing matching contacts */ +"chats.sendDM.matchingContacts" = "Selecione um contacto para enviar mensagem:"; + +/* Location: SendDMSheet.swift - Purpose: Empty-state message when no contact matches */ +"chats.sendDM.noMatches" = "Não foi encontrado nenhum contacto chamado \"%@\"."; + +/* Location: SendDMSheet.swift - Purpose: Cancel button */ +"chats.sendDM.cancel" = "Cancelar"; + +// MARK: - Malware Warning + +/* Location: MalwareWarningCard.swift - Warning title for flagged domains */ +"chats.malwareWarning.title" = "Ligação assinalada como suspeita"; + +/* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ +"chats.malwareWarning.accessibility" = "Aviso: ligação para %@ assinalada como suspeita"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Região"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "Sem scope"; + +/* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ +"chats.channelInfo.region.useDefaultFormat" = "%@ (predefinição)"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Não configurada"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limitar as mensagens a uma área geográfica"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Descobrir regiões próximas"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "A descobrir…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regiões"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Gerir regiões"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Adicionar manualmente"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "Nenhuma região adicionada"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Descubra regiões em repetidores próximos ou adicione-as manualmente."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "Nenhuma região nova encontrada"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "Nenhum repetidor respondeu"; + +/* Location: ChannelInfoSheet.swift - Purpose: Couldn't load nearby repeater list */ +"chats.channelInfo.region.errLoadingRepeaters" = "Não foi possível carregar os repetidores próximos"; + +/* Location: ChannelInfoSheet.swift - Purpose: Some queries failed because radio contact list is full */ +"chats.channelInfo.region.errRadioContactsFull" = "A lista de contactos do rádio está cheia. Não foi possível obter dados de alguns repetidores"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Adicionar"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Adicionar região"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Nome da região"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Os nomes de região só podem conter letras, números e hífenes."; + +/* Location: RegionManagementView.swift - Purpose: Validation error when a region name exceeds the firmware byte cap */ +"chats.channelInfo.region.nameTooLong" = "Os nomes de região estão limitados a %d bytes."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "Esta região já está na lista."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Privada"; + +/* Location: ChatConversationType.swift - Purpose: Subtitle suffix when channel region matches the device default flood scope */ +"chats.channelInfo.region.scopedDefault" = "%@ (predefinição)"; + +// MARK: - Common + +/* Location: ChatConversationView.swift - Conversation toolbar Info button */ +"chats.common.info" = "Informação"; + +// MARK: - Mention Picker + +/* Location: ChatConversationView.swift - Mention picker navigation title shown with the tapped mention name */ +"chats.mention.picker.title" = "Menção: %@"; + +/* Location: ChatConversationView.swift - Mention picker title when the tapped name matches no saved contact */ +"chats.mention.picker.notSavedTitle" = "Não é um contacto guardado"; + +/* Location: ChatConversationView.swift - Mention picker description when the tapped name matches no saved contact */ +"chats.mention.picker.notSavedSubtitle" = "@%@ não corresponde a nenhum contacto neste rádio."; + +/* Location: ChatConversationView.swift - Mention picker title when the tapped name is the user's own node */ +"chats.mention.picker.selfTitle" = "É o seu nó"; + +/* Location: ChatConversationView.swift - Mention picker description when the tapped name is the user's own node */ +"chats.mention.picker.selfSubtitle" = "@%@ é o nome deste nó."; + +/* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ +"chats.mention.picker.matchingContacts" = "Contactos correspondentes"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Nome não verificado"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Os remetentes das mensagens do canal não podem ser verificados. Pode tratar-se de outra pessoa."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Correspondência de nome não verificado"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/pt.lproj/Contacts.strings b/MC1/Resources/Localization/pt.lproj/Contacts.strings new file mode 100644 index 000000000..af52b4a1f --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Contacts.strings @@ -0,0 +1,1250 @@ +/* + Contacts.strings + MC1 + + European Portuguese (pt-PT) translation of contacts and nodes strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Common + +/* Location: Multiple files - Purpose: Generic OK button */ +"contacts.common.ok" = "OK"; + +/* Location: Multiple files - Purpose: Generic Cancel button */ +"contacts.common.cancel" = "Cancelar"; + +/* Location: Multiple files - Purpose: Generic Save button */ +"contacts.common.save" = "Guardar"; + +/* Location: Multiple files - Purpose: Generic Done button */ +"contacts.common.done" = "Concluído"; + +/* Location: Multiple files - Purpose: Generic Delete button */ +"contacts.common.delete" = "Eliminar"; + +/* Location: Multiple files - Purpose: Generic Edit button */ +"contacts.common.edit" = "Editar"; + +/* Location: Multiple files - Purpose: Generic Error alert title */ +"contacts.common.error" = "Erro"; + +/* Location: Multiple files - Purpose: Fallback error message */ +"contacts.common.errorOccurred" = "Ocorreu um erro"; + +// MARK: - Contact Types + +/* Location: Multiple files - Purpose: Chat contact type label */ +"contacts.nodeKind.chat" = "Chat"; + +/* Location: Multiple files - Purpose: Chat contact full label */ +/* Location: Multiple files - Purpose: Repeater contact type label */ +"contacts.nodeKind.repeater" = "Repetidor"; + +/* Location: Multiple files - Purpose: Room contact type label */ +"contacts.nodeKind.room" = "Sala"; + +/* Location: TracePathListView.swift, PathEditingSheet.swift - Purpose: Badge label for discovered nodes */ +"contacts.nodeKind.discovered" = "Descoberto"; + +/* Location: ContactsListView.swift - Purpose: Contact label in search results */ +"contacts.nodeKind.contact" = "Contacto"; + +// MARK: - Route Types + +/* Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Flood routing label */ +"contacts.route.flood" = "Difusão"; + +/* Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Direct routing label */ +"contacts.route.direct" = "Direto"; + +/* Location: ContactRowView.swift - Purpose: Hops count display */ +"contacts.route.hops" = "%d saltos"; + +// MARK: - Node Segments + +/* Location: ContactsViewModel.swift - Purpose: Favorites segment */ +"contacts.segment.favorites" = "Favoritos"; + +/* Location: ContactsViewModel.swift - Purpose: Contacts segment */ +"contacts.segment.contacts" = "Contactos"; + +/* Location: ContactsViewModel.swift - Purpose: Repeaters segment */ +"contacts.segment.repeaters" = "Repetidores"; + +/* Location: ContactsViewModel.swift - Purpose: Rooms segment */ +"contacts.segment.rooms" = "Salas"; + +/* Location: NodeSegmentPicker.swift - Purpose: VoiceOver label for the Nodes tab segment filter picker (iOS 18 fallback) */ +"contacts.segment.pickerLabel" = "Filtrar nós"; + +// MARK: - Sort Order + +/* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ +"contacts.sort.lastHeard" = "Última alteração"; + +/* Location: ContactsViewModel.swift - Purpose: Name sort option */ +"contacts.sort.name" = "Nome"; + +/* Location: ContactsViewModel.swift - Purpose: Distance sort option */ +"contacts.sort.distance" = "Distância"; + +/* Location: ContactsViewModel.swift - Purpose: Hops sort option */ +"contacts.sort.hops" = "Saltos"; + +// MARK: - Contacts List + +/* Location: ContactsListView.swift - Purpose: Navigation title */ +"contacts.list.title" = "Nós"; + +/* Location: ContactsListView.swift - Purpose: Search prompt */ +"contacts.list.searchPrompt" = "Pesquisar nós"; + +/* Location: ContactsListView.swift - Purpose: Search prompt with count */ +"contacts.list.searchPromptWithCount" = "Pesquisar nós (%d)"; + +/* Location: ContactsListView.swift - Purpose: Sort menu label */ +"contacts.list.sort" = "Ordenar"; + +/* Location: ContactsListView.swift - Purpose: Options menu label */ +"contacts.list.options" = "Opções"; + +/* Location: ContactsListView.swift - Purpose: Menu item for blocked contacts */ +"contacts.list.blockedContacts" = "Contactos bloqueados"; + +/* Location: ContactsListView.swift - Purpose: Menu item to share own contact */ +"contacts.list.shareMyContact" = "Partilhar o meu contacto"; + +/* Location: ContactsListView.swift - Purpose: Menu item to add contact */ +"contacts.list.addContact" = "Adicionar contacto"; + +/* Location: ContactsListView.swift - Purpose: Menu item for discovery */ +"contacts.list.discover" = "Descobrir"; + +/* Location: ContactsListView.swift - Purpose: Menu item to sync nodes */ +"contacts.list.syncNodes" = "Sincronizar nós"; + +/* Location: ContactsListView.swift - Purpose: Empty state for split view */ +"contacts.list.selectNode" = "Selecione um nó"; + +/* Location: ContactsListView.swift - Purpose: Refresh alert title */ +"contacts.list.cannotRefresh" = "Não é possível atualizar"; + +/* Location: ContactsListView.swift - Purpose: Refresh alert message */ +"contacts.list.connectToSync" = "Ligue o dispositivo para sincronizar os contactos."; + +/* Location: ContactsListView.swift - Purpose: Location alert title */ +"contacts.list.locationUnavailable" = "Localização indisponível"; + +/* Location: ContactsListView.swift - Purpose: Location settings button */ +"contacts.list.openSettings" = "Abrir Definições"; + +/* Location: ContactsListView.swift - Purpose: Location alert message */ +"contacts.list.distanceRequiresLocation" = "A ordenação por distância requer acesso à localização."; + +/* Location: ContactsListView.swift - Purpose: VoiceOver offline announcement */ +"contacts.list.offlineAnnouncement" = "A mostrar contactos em cache. Ligue o dispositivo para atualizar."; + +// MARK: - Contacts List Empty States + +/* Location: ContactsListView.swift - Purpose: No favorites empty title */ +"contacts.list.empty.favorites.title" = "Ainda não há favoritos"; + +/* Location: ContactsListView.swift - Purpose: No favorites empty description */ +"contacts.list.empty.favorites.description" = "Mantenha premido qualquer nó para o adicionar aos favoritos."; + +/* Location: ContactsListView.swift - Purpose: No contacts empty title */ +"contacts.list.empty.contacts.title" = "Nenhum contacto"; + +/* Location: ContactsListView.swift - Purpose: No contacts empty description */ +"contacts.list.empty.contacts.description" = "Os contactos aparecem quando são descobertos na rede mesh. Se a adição automática de contactos estiver desativada, consulte Descobrir no menu no canto superior direito."; + +/* Location: ContactsListView.swift - Purpose: No repeaters empty title */ +"contacts.list.empty.repeaters.title" = "Nenhum repetidor"; + +/* Location: ContactsListView.swift - Purpose: No repeaters empty description */ +"contacts.list.empty.repeaters.description" = "Os repetidores alargam o alcance da mesh. Aparecem aqui quando forem descobertos."; + +/* Location: ContactsListView.swift - Purpose: No rooms empty title */ +"contacts.list.empty.rooms.title" = "Nenhuma sala"; + +/* Location: ContactsListView.swift - Purpose: No rooms empty description */ +"contacts.list.empty.rooms.description" = "As salas são servidores de chat públicos na mesh. Aparecem aqui quando forem descobertas."; + +/* Location: ContactsListView.swift - Purpose: No search results title */ +"contacts.list.empty.search.title" = "Nenhum resultado"; + +/* Location: ContactsListView.swift - Purpose: No search results description */ +"contacts.list.empty.search.description" = "Nenhum nó corresponde a '%@'"; + +// MARK: - Contacts List Row + +/* Location: ContactRowView.swift - Purpose: Blocked status accessibility label */ +"contacts.row.blocked" = "Bloqueado"; + +/* Location: ContactRowView.swift - Purpose: Favorite status accessibility label */ +"contacts.row.favorite" = "Favorito"; + +/* Location: ContactRowView.swift - Purpose: Location indicator accessibility label */ +"contacts.row.location" = "Localização"; + +/* Location: ContactRowView.swift - Purpose: Distance suffix */ +"contacts.row.away" = "a %@"; + +// MARK: - Contacts List Swipe Actions + +/* Location: ContactContextMenuModifier - Purpose: Unfavorite action */ +"contacts.action.unfavorite" = "Remover dos favoritos"; + +/* Location: ContactContextMenuModifier - Purpose: Block action */ +"contacts.action.block" = "Bloquear"; + +/* Location: ContactContextMenuModifier - Purpose: Unblock action */ +"contacts.action.unblock" = "Desbloquear"; + +// MARK: - Contact Detail + +/* Location: ContactDetailView.swift - Purpose: Favorite status indicator */ +"contacts.detail.favorite" = "Favorito"; + +/* Location: ContactDetailView.swift - Purpose: Blocked status indicator */ +"contacts.detail.blocked" = "Bloqueado"; + +/* Location: ContactDetailView.swift - Purpose: Has location status indicator */ +"contacts.detail.hasLocation" = "Tem localização"; + +/* Location: ContactDetailView.swift - Purpose: Join room button */ +"contacts.detail.joinRoom" = "Entrar na sala"; + +/* Location: ContactDetailView.swift - Purpose: Telemetry button */ +"contacts.detail.telemetry" = "Telemetria"; + +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Histórico de telemetria"; + +/* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ +"contacts.detail.telemetryAccess" = "Acesso à telemetria"; + +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Gestão"; + +/* Location: ContactDetailView.swift - Purpose: Send message button */ +"contacts.detail.sendMessage" = "Enviar mensagem"; + +/* Location: ContactDetailView.swift - Purpose: Add to favorites button */ +"contacts.detail.addToFavorites" = "Adicionar aos favoritos"; + +/* Location: ContactDetailView.swift - Purpose: Remove from favorites button */ +"contacts.detail.removeFromFavorites" = "Remover dos favoritos"; + +/* Location: ContactDetailView.swift - Purpose: Share contact button */ +"contacts.detail.shareContact" = "Partilhar contacto"; + +/* Location: ContactDetailView.swift - Purpose: Share via advert button */ +"contacts.detail.shareViaAdvert" = "Partilhar contacto via Advert"; + +/* Location: ContactDetailView.swift - Purpose: Share contact error when advert is missing or stale */ +"contacts.detail.shareContactUnavailable" = "Não foi possível partilhar o nó. O Advert do nó pode estar em falta ou ser demasiado antigo."; + +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping zero-hop"; + +/* Location: ContactDetailView.swift - Purpose: Ping no response message */ +"contacts.detail.pingNoResponse" = "Sem resposta"; + +/* Location: ContactDetailView.swift - Purpose: Ping success accessibility label */ +"contacts.detail.pingSuccessLabel" = "Ping bem-sucedido, %1$d milissegundos, %2$d decibéis na ida, %3$d no regresso"; + +/* Location: ContactDetailView.swift - Purpose: Ping failure accessibility label */ +"contacts.detail.pingFailureLabel" = "Falha no ping: %@"; + +/* Location: ContactDetailView.swift - Purpose: Ping success VoiceOver announcement */ +"contacts.detail.pingSuccessAnnouncement" = "Ping bem-sucedido, %d milissegundos"; + +/* Location: ContactDetailView.swift - Purpose: Ping failure VoiceOver announcement */ +"contacts.detail.pingFailureAnnouncement" = "Falha no ping"; + +// MARK: - Contact Detail Info Section + +/* Location: ContactDetailView.swift - Purpose: Info section header */ +"contacts.detail.info" = "Informações"; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu title */ +"contacts.detail.avatar.chooseSource" = "Alterar foto de perfil"; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick a photo from the photo library */ +"contacts.detail.avatar.choosePhoto" = "Escolher foto"; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick an image file */ +"contacts.detail.avatar.chooseFile" = "Escolher ficheiro..."; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu option to remove the current photo */ +"contacts.detail.avatar.removePhoto" = "Remover foto"; + +/* Location: ContactDetailView.swift - Purpose: Error shown when the picked file isn't a valid image */ +"contacts.detail.avatar.invalidImage" = "Não foi possível usar esse ficheiro como foto de perfil."; + +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Mover e ajustar escala"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Escolher"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Recorte da foto de perfil"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Deslize para cima ou para baixo para ampliar. Use as ações para mover a foto."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Mover para cima"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Mover para baixo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Mover para a esquerda"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Mover para a direita"; + +/* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ +"contacts.detail.avatar.savingAnnouncement" = "A guardar foto"; + +/* Location: ContactDetailView.swift - Purpose: Nickname label */ +"contacts.detail.nickname" = "Alcunha"; + +/* Location: ContactDetailView.swift - Purpose: No nickname placeholder */ +"contacts.detail.nicknameNone" = "Nenhuma"; + +/* Location: ContactDetailView.swift - Purpose: Name label */ +"contacts.detail.name" = "Nome"; + +/* Location: ContactDetailView.swift - Purpose: Last advert label */ +"contacts.detail.lastAdvert" = "Último Advert"; + +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Última receção"; + +/* Location: ContactDetailView.swift - Purpose: Unread messages label */ +"contacts.detail.unreadMessages" = "Mensagens por ler"; + +// MARK: - Contact Detail Location Section + +/* Location: ContactDetailView.swift - Purpose: Location section header */ +"contacts.detail.location" = "Localização"; + +/* Location: ContactDetailView.swift - Purpose: Coordinates label */ +"contacts.detail.coordinates" = "Coordenadas"; + +/* Location: ContactDetailView.swift - Purpose: Open in Maps button */ +"contacts.detail.openInMaps" = "Abrir em Mapas"; + +// MARK: - Contact Detail Network Path Section + +/* Location: ContactDetailView.swift - Purpose: Outbound path section header */ +"contacts.detail.outboundPath" = "Rota de saída"; + +/* Location: ContactDetailView.swift - Purpose: Route label */ +"contacts.detail.route" = "Rota"; + +/* Location: ContactDetailView.swift - Purpose: Copy route to clipboard */ +"contacts.detail.copyRoute" = "Copiar rota"; + +/* Location: ContactDetailView.swift - Purpose: Hops away label */ +"contacts.detail.hopsAway" = "Distância em saltos"; + +/* Location: ContactDetailView.swift - Purpose: Path discovery in progress */ +"contacts.detail.discoveringPath" = "A descobrir a rota..."; + +/* Location: ContactDetailView.swift - Purpose: Discovery countdown */ +"contacts.detail.secondsRemaining" = "Até %d segundos restantes"; + +/* Location: ContactDetailView.swift - Purpose: Discover path button */ +"contacts.detail.discoverPath" = "Descobrir rota"; + +/* Location: ContactDetailView.swift - Purpose: Edit path button */ +"contacts.detail.editPath" = "Editar rota"; + +/* Location: ContactDetailView.swift - Purpose: Reset path button */ +"contacts.detail.resetPath" = "Repor rota"; + +/* Location: ContactDetailView.swift - Purpose: Footer for flood routing */ +"contacts.detail.floodFooter" = "As mensagens são enviadas a todos os nós. Use Descobrir rota para encontrar a melhor."; + +/* Location: ContactDetailView.swift - Purpose: Footer for path routing */ +"contacts.detail.pathFooter" = "As mensagens seguem a rota mostrada. Reponha-a para voltar à difusão."; + +/* Location: ContactDetailView.swift - Purpose: Accessibility label for flood route */ +"contacts.detail.routeFlood" = "Rota: Difusão"; + +/* Location: ContactDetailView.swift - Purpose: Accessibility label for direct route */ +"contacts.detail.routeDirect" = "Rota: Direto"; + +/* Location: ContactDetailView.swift - Purpose: Accessibility label prefix for route */ +"contacts.detail.routePrefix" = "Rota: %@"; + +// MARK: - Contact Detail Technical Section + +/* Location: ContactDetailView.swift - Purpose: Technical section header */ +"contacts.detail.technical" = "Detalhes técnicos"; + +/* Location: ContactDetailView.swift - Purpose: Public key label */ +"contacts.detail.publicKey" = "Chave pública"; + +/* Location: ContactDetailView.swift - Purpose: Type label */ +"contacts.detail.type" = "Tipo"; + +// MARK: - Contact Detail Danger Section + +/* Location: ContactDetailView.swift - Purpose: Danger zone section header */ +"contacts.detail.dangerZone" = "Zona perigosa"; + +/* Location: ContactDetailView.swift - Purpose: Clear messages button */ +"contacts.detail.clearMessages" = "Limpar mensagens"; + +/* Location: ContactDetailView.swift - Purpose: Clear messages services-unavailable error */ +"contacts.detail.error.servicesUnavailable" = "Serviços indisponíveis"; + +/* Location: ContactDetailView.swift - Purpose: Block contact button */ +"contacts.detail.blockContact" = "Bloquear contacto"; + +/* Location: ContactDetailView.swift - Purpose: Unblock contact button */ +"contacts.detail.unblockContact" = "Desbloquear contacto"; + +/* Location: ContactDetailView.swift - Purpose: Delete button with type */ +"contacts.detail.deleteType" = "Eliminar %@"; + +// MARK: - Contact Detail Alerts + +/* Location: ContactDetailView.swift - Purpose: Block contact alert title */ +"contacts.detail.alert.block.title" = "Bloquear contacto"; + +/* Location: ContactDetailView.swift - Purpose: Block contact alert message */ +"contacts.detail.alert.block.message" = "Deixará de receber mensagens de %@. A conversa fica oculta na lista e as mensagens deste contacto nos canais são ignoradas. Se desbloquear, volta a receber mensagens, mas as que foram ignoradas não são recuperadas."; + +/* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ +"contacts.detail.alert.delete.title" = "Eliminar %@"; + +/* Location: ContactDetailView.swift - Purpose: Delete contact alert message */ +"contacts.detail.alert.delete.message" = "%@ e todos os dados associados são eliminados. Esta ação não pode ser anulada."; + +/* Location: ContactDetailView.swift - Purpose: Clear messages alert title */ +"contacts.detail.alert.clearMessages.title" = "Limpar mensagens?"; + +/* Location: ContactDetailView.swift - Purpose: Clear messages alert message */ +"contacts.detail.alert.clearMessages.message" = "Todas as mensagens com %@ são eliminadas de forma permanente."; + +/* Location: ContactDetailView.swift - Purpose: Path error alert title */ +"contacts.detail.alert.pathError" = "Erro de rota"; + +/* Location: ContactDetailView.swift - Purpose: Path discovery alert title */ +"contacts.detail.alert.pathDiscovery" = "Descoberta de rota"; + +// MARK: - Blocked Contacts + +/* Location: BlockedContactsView.swift - Purpose: Navigation title */ +"contacts.blocked.title" = "Contactos bloqueados"; + +/* Location: BlockedContactsView.swift - Purpose: Loading progress */ +"contacts.blocked.loading" = "A carregar..."; + +/* Location: BlockedContactsView.swift - Purpose: Empty state title */ +"contacts.blocked.empty.title" = "Nenhum contacto bloqueado"; + +/* Location: BlockedContactsView.swift - Purpose: Empty state description */ +"contacts.blocked.empty.description" = "Os contactos bloqueados aparecem aqui."; + +// MARK: - Add Contact Sheet + +/* Location: AddContactSheet.swift - Purpose: Navigation title */ +"contacts.add.title" = "Adicionar contacto"; +"contacts.add.nodeTitle" = "Adicionar nó"; + +/* Location: AddContactSheet.swift - Purpose: Add button */ +"contacts.add.add" = "Adicionar"; + +/* Location: AddContactSheet.swift - Purpose: Scan QR button label */ +"contacts.add.scanQR" = "Ler código QR"; + +/* Location: AddContactSheet.swift - Purpose: Type section header */ +"contacts.add.type" = "Tipo"; + +/* Location: AddContactSheet.swift - Purpose: Name section header */ +"contacts.add.name" = "Nome"; + +/* Location: AddContactSheet.swift - Purpose: Contact name placeholder */ +"contacts.add.contactName" = "Nome do contacto"; + +/* Location: AddContactSheet.swift - Purpose: Public key section header */ +"contacts.add.publicKey" = "Chave pública"; + +/* Location: AddContactSheet.swift - Purpose: Public key placeholder */ +"contacts.add.hexPlaceholder" = "%d caracteres hexadecimais"; + +/* Location: AddContactSheet.swift - Purpose: Valid key status */ +"contacts.add.valid" = "Válida"; + +/* Location: AddContactSheet.swift - Purpose: Character count status */ +"contacts.add.characterCount" = "%d/%d caracteres"; + +/* Location: AddContactSheet.swift - Purpose: Public key footer */ +"contacts.add.publicKeyFooter" = "Introduza a chave pública hexadecimal de %d caracteres do contacto"; + +/* Location: AddContactSheet.swift - Purpose: Not connected error */ +"contacts.add.error.notConnected" = "Não ligado ao dispositivo"; + +/* Location: AddContactSheet.swift - Purpose: Invalid public key format error */ +"contacts.add.error.invalidFormat" = "Formato de chave pública inválido"; + +/* Location: AddContactSheet.swift - Purpose: Invalid public key size error */ +"contacts.add.error.invalidSize" = "A chave pública tem de ter %d bytes (%d caracteres hexadecimais)"; + +/* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error with max count */ +"contacts.add.error.nodeListFull" = "A lista de nós está cheia (máximo de %d nós)"; + +/* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ +"contacts.add.error.nodeListFullSimple" = "A lista de nós está cheia"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Colar URL do contacto"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Cole uma ligação meshcore:// de contacto para preencher automaticamente os campos acima"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "A área de transferência não contém um URL de contacto válido"; + +// MARK: - Contact QR Share Sheet + +/* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ +"contacts.qr.title" = "Partilhar contacto"; + +/* Location: ContactQRShareSheet.swift - Purpose: Share subject */ +"contacts.qr.shareSubject" = "Contacto MeshCore One"; + +/* Location: ContactQRShareSheet.swift - Purpose: Copy button when not copied */ +"contacts.qr.copy" = "Copiar"; + +/* Location: ContactQRShareSheet.swift - Purpose: Copy button when copied */ +"contacts.qr.copied" = "Copiado!"; + +/* Location: ContactQRShareSheet.swift - Purpose: Share button */ +"contacts.qr.share" = "Partilhar"; + +/* Location: ContactQRShareSheet.swift - Purpose: Contact label in share text, %@ is contact name */ +"contacts.share.contactLabel" = "Contacto MeshCore One: %@"; + +/* Location: ContactQRShareSheet.swift - Purpose: Key label in share text, %@ is hex public key */ +"contacts.share.keyLabel" = "Chave: %@"; + +// MARK: - Scan Contact QR + +/* Location: ScanContactQRView.swift - Purpose: Navigation title */ +"contacts.scan.title" = "Ler código QR"; + +/* Location: ScanContactQRView.swift - Purpose: Scanner not available title */ +"contacts.scan.unavailable.title" = "Scanner indisponível"; + +/* Location: ScanContactQRView.swift - Purpose: Scanner not available description */ +"contacts.scan.unavailable.description" = "A leitura de códigos QR não é suportada neste dispositivo"; + +/* Location: ScanContactQRView.swift - Purpose: Importing progress */ +"contacts.scan.importing" = "A importar o contacto..."; + +/* Location: ScanContactQRView.swift - Purpose: Scan instruction */ +"contacts.scan.instruction" = "Aponte a câmara para um código QR de contacto"; + +/* Location: ScanContactQRView.swift - Purpose: Camera permission title */ +"contacts.scan.permission.title" = "É necessário o acesso à câmara"; + +/* Location: ScanContactQRView.swift - Purpose: Camera permission description */ +"contacts.scan.permission.description" = "Ative o acesso à câmara em Definições para ler códigos QR."; + +/* Location: ScanContactQRView.swift - Purpose: Invalid QR format error */ +"contacts.scan.error.invalidFormat" = "Formato de código QR inválido"; + +/* Location: ScanContactQRView.swift - Purpose: Missing name error */ +/* Location: ScanContactQRView.swift - Purpose: Invalid public key error */ +/* Location: ScanContactQRView.swift - Purpose: Contact import failed, %@ is the error description */ +"contacts.scan.error.importFailed" = "Falha ao importar o contacto: %@"; + +// MARK: - Discovery View + +/* Location: DiscoveryView.swift - Purpose: Navigation title */ +"contacts.discovery.title" = "Descobrir"; + +/* Location: DiscoveryView.swift - Purpose: Empty state title */ +"contacts.discovery.empty.title" = "Nenhum nó descoberto"; + +/* Location: DiscoveryView.swift - Purpose: Empty state description */ +"contacts.discovery.empty.description" = "Os nós aparecem aqui à medida que os respetivos Adverts são descobertos."; + +/* Location: DiscoveryView.swift - Purpose: Add button */ +"contacts.discovery.add" = "Adicionar"; + +/* Location: DiscoveryView.swift - Purpose: Button label when node is already added */ +"contacts.discovery.added" = "Adicionado"; + +/* Location: DiscoveryView.swift - Purpose: Accessibility label for added button */ +"contacts.discovery.addedAccessibility" = "Já adicionado aos contactos"; + +/* Location: DiscoveryView.swift - Purpose: Services not available error */ +/* Location: DiscoveryView.swift - Purpose: Segment filter: All */ +"contacts.discovery.segment.all" = "Todos"; + +/* Location: DiscoveryView.swift - Purpose: Segment filter: Contacts */ +"contacts.discovery.segment.contacts" = "Contactos"; + +/* Location: DiscoveryView.swift - Purpose: Segment filter: Repeaters */ +"contacts.discovery.segment.repeaters" = "Repetidores"; + +/* Location: DiscoveryView.swift - Purpose: Segment filter: Rooms */ +"contacts.discovery.segment.rooms" = "Salas"; + +/* Location: DiscoverSegmentPicker.swift - Purpose: VoiceOver label for the Discovery segment filter picker (iOS 18 fallback) */ +"contacts.discovery.segment.pickerLabel" = "Filtrar nós descobertos"; + +/* Location: DiscoveryView.swift - Purpose: Search prompt */ +"contacts.discovery.searchPrompt" = "Pesquisar descobertos"; + +/* Location: DiscoveryView.swift - Purpose: More menu accessibility label */ +"contacts.discovery.menu" = "Mais"; + +/* Location: DiscoveryView.swift - Purpose: Clear all menu item */ +"contacts.discovery.clear" = "Limpar tudo"; + +/* Location: DiscoveryView.swift - Purpose: Clear confirmation title */ +"contacts.discovery.clear.title" = "Limpar todos os nós descobertos?"; + +/* Location: DiscoveryView.swift - Purpose: Clear confirmation message */ +"contacts.discovery.clear.message" = "Os nós descobertos serão removidos desta lista, mas podem ser descobertos novamente na rede mesh."; + +/* Location: DiscoveryView.swift - Purpose: Clear confirmation button */ +"contacts.discovery.clear.confirm" = "Limpar"; + +/* Location: DiscoveryView.swift - Purpose: VoiceOver announcement when searching */ +"contacts.discovery.searchingAllTypes" = "A pesquisar todos os tipos"; + +/* Location: DiscoveryView.swift - Purpose: Sort menu accessibility label */ +"contacts.discovery.sortMenu" = "Opções de ordenação"; + +/* Location: DiscoveryView.swift - Purpose: Sort menu accessibility hint */ +"contacts.discovery.sortMenuHint" = "Escolha como ordenar os nós descobertos"; + +/* Location: DiscoveryView.swift - Purpose: VoiceOver announcement after clearing */ +"contacts.discovery.clearedAllNodes" = "Todos os nós descobertos foram limpos"; + +/* Location: DiscoveryView.swift - Purpose: Swipe action to remove discovered node */ +"contacts.discovery.remove" = "Remover"; + +/* Location: DiscoveryView.swift - Purpose: Search empty state title */ +"contacts.discovery.empty.search.title" = "Nenhum resultado"; + +/* Location: DiscoveryView.swift - Purpose: Search empty state description */ +"contacts.discovery.empty.search.description" = "Nenhum nó descoberto corresponde a '%@'"; + +// MARK: - Path Editing Sheet + +/* Location: PathEditingSheet.swift - Purpose: Navigation title */ +"contacts.pathEdit.title" = "Editar rota"; + +/* Location: PathEditingSheet.swift - Purpose: Description with contact name */ +"contacts.pathEdit.description" = "Personalize a rota que as mensagens seguem para chegar a %@."; + +/* Location: PathEditingSheet.swift - Purpose: Current path section header */ +"contacts.pathEdit.currentPath" = "Rota atual"; + +/* Location: AddHopPickerView.swift - Purpose: No repeaters empty title */ +"contacts.pathEdit.noRepeaters.title" = "Nenhum repetidor disponível"; + +/* Location: AddHopPickerView.swift - Purpose: No repeaters empty description */ +"contacts.pathEdit.noRepeaters.description" = "Os repetidores aparecem aqui depois de serem descobertos na rede mesh."; + +/* Location: AddHopPickerView.swift - Purpose: No favorite repeaters empty title */ +"contacts.pathEdit.noFavorites.title" = "Nenhum repetidor favorito"; + +/* Location: AddHopPickerView.swift - Purpose: No favorite repeaters empty description */ +"contacts.pathEdit.noFavorites.description" = "Os repetidores marcados como favoritos aparecem aqui."; + +/* Location: AddHopPickerView.swift - Purpose: No recent repeaters empty title */ +"contacts.pathEdit.noRecent.title" = "Nenhum repetidor recente"; + +/* Location: AddHopPickerView.swift - Purpose: No recent repeaters empty description */ +"contacts.pathEdit.noRecent.description" = "Os repetidores adicionados a uma rota aparecem aqui."; + +/* Location: PathEditingSheet.swift - Purpose: Hop accessibility with name */ +"contacts.pathEdit.hopWithName" = "Salto %d de %d: %@"; + +/* Location: PathEditingSheet.swift - Purpose: Hop accessibility with hex */ +"contacts.pathEdit.hopWithHex" = "Salto %d de %d: repetidor %@"; + +/* Location: PathEditingSheet.swift - Purpose: Add Hop CTA (primary button when path has >=1 hop) */ +"contacts.pathEdit.addHop" = "Adicionar salto"; + +/* Location: AddHopPickerView.swift - Purpose: Paste clipboard text into the hop search field */ +"contacts.pathEdit.paste" = "Colar da área de transferência"; + +/* Location: PathEditingSheet.swift - Purpose: Use-direct-routing empty-state button */ +"contacts.pathEdit.useDirectRouting" = "Usar rota direta"; + +/* Location: PathEditingSheet.swift - Purpose: Use-flood-routing empty-state button */ +"contacts.pathEdit.useFloodRouting" = "Usar difusão"; + +/* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation alert title */ +"contacts.pathEdit.directRouting.confirm.title" = "Guardar como rota direta?"; + +/* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation alert message, %@ is contact name */ +"contacts.pathEdit.directRouting.confirm.message" = "As mensagens para %@ serão enviadas diretamente, sem passar por nenhum repetidor. Use apenas se %@ for um vizinho direto (0-hop)."; + +/* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation confirm label */ +"contacts.pathEdit.directRouting.confirm.confirm" = "Guardar como direto"; + +/* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation alert title */ +"contacts.pathEdit.floodRouting.confirm.title" = "Usar rota por difusão?"; + +/* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation alert message, %@ is contact name */ +"contacts.pathEdit.floodRouting.confirm.message" = "As mensagens para %@ serão enviadas a todos os nós próximos até ser encontrada uma rota. Use quando não for conhecida nenhuma rota de repetidor."; + +/* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation confirm label */ +"contacts.pathEdit.floodRouting.confirm.confirm" = "Usar rota por difusão"; + +/* Location: AddHopPickerView.swift - Purpose: Position banner when appending, %d is target hop number */ +"contacts.pathEdit.positionAppend" = "A adicionar como salto %d"; + +/* Location: AddHopPickerView.swift - Purpose: Search field placeholder */ +"contacts.pathEdit.searchPrompt" = "A1,2B ou nome/ID"; + +/* Location: PathEditingSheet.swift - Purpose: Section footer hint */ +"contacts.pathEdit.reorderHint" = "Arraste para reordenar · Deslize para a esquerda para remover"; + +/* Location: PathEditingSheet.swift - Purpose: Empty-state title */ +"contacts.pathEdit.empty.title" = "Ainda não há saltos"; + +/* Location: PathEditingSheet.swift - Purpose: Empty-state description, %@ is contact name */ +"contacts.pathEdit.empty.description" = "Adicione repetidores para controlar como as mensagens chegam a %@, ou use a difusão se a rota não for conhecida."; + +/* Location: AddHopSegmentPicker.swift - Purpose: All filter */ +"contacts.pathEdit.filter.all" = "Todos"; + +/* Location: AddHopSegmentPicker.swift - Purpose: Favorites filter */ +"contacts.pathEdit.filter.favorites" = "Favoritos"; + +/* Location: AddHopSegmentPicker.swift - Purpose: Recent filter */ +"contacts.pathEdit.filter.recent" = "Recentes"; + +/* Location: AddHopSegmentPicker.swift - Purpose: Discovered filter */ +"contacts.pathEdit.filter.discovered" = "Descobertos"; + +/* Location: AddHopPickerView.swift - Purpose: Recent section header */ +"contacts.pathEdit.sections.recent" = "Recentes"; + +/* Location: AddHopPickerView.swift - Purpose: Favorites section header */ +"contacts.pathEdit.sections.favorites" = "Favoritos"; + +/* Location: AddHopPickerView.swift - Purpose: Contacts section header */ +"contacts.pathEdit.sections.contacts" = "Contactos"; + +/* Location: AddHopPickerView.swift - Purpose: Discovered section header */ +"contacts.pathEdit.sections.discovered" = "Descobertos"; + +/* Location: AddHopPickerView.swift - Purpose: Rooms section header */ +"contacts.pathEdit.sections.rooms" = "Salas"; + +/* Location: AddHopPickerView.swift - Purpose: No-match search description */ +"contacts.pathEdit.search.noMatches.description" = "Tente um nome ou um prefixo hexadecimal como 'a3'."; + +/* Location: AddHopPickerView.swift - Purpose: No-match description when a filter other than All is selected and a matching room exists */ +"contacts.pathEdit.search.noMatches.descriptionWithRoomsHint" = "Tente um nome ou um prefixo hexadecimal como 'a3'. As salas só aparecem no filtro Todos."; + +/* Location: AddHopPickerView.swift - Purpose: Picker row accessibility label, %1$@ is name, %2$d is target hop number */ +"contacts.pathEdit.addToPathAsHop" = "Adicionar %1$@ à rota como salto %2$d"; + +/* Location: PathEditingSheet.swift - Purpose: VoiceOver hint for a hop row describing swipe + drag actions */ +"contacts.pathEdit.hopHint" = "Deslize para eliminar. Arraste para reordenar."; + +/* Location: AddHopSegmentPicker.swift - Purpose: VoiceOver label for the section filter picker */ +"contacts.pathEdit.filterPickerLabel" = "Filtrar saltos"; + +/* Location: PathEditingSheet.swift + AddHopPickerView.swift - Purpose: Max-hops reached title */ +"contacts.pathEdit.maxHops.reached" = "Máximo de saltos atingido"; + +/* Location: AddHopPickerView.swift - Purpose: Max-hops reached description, %d is the cap */ +"contacts.pathEdit.maxHops.description" = "Esta rota atingiu o máximo de %d saltos no modo de hash atual. Remova um salto para adicionar outro."; + +/* Location: PathEditingSheet.swift - Purpose: Add Hop CTA footer explaining the hop cap, %d is the cap */ +"contacts.pathEdit.maxHops.footer" = "Máximo de %d saltos atingido. Remova um salto para adicionar outro."; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add action button, %@ is the comma-joined codes */ +"contacts.pathEdit.bulkAdd.action" = "Adicionar nós: %@"; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add row, code will be added, %@ is the code */ +"contacts.pathEdit.bulkAdd.willAdd" = "%@ será adicionado"; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add row, code skipped past the hop cap, %@ is the code */ +"contacts.pathEdit.bulkAdd.pathFull" = "%@ excede o limite de saltos"; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add action button when nothing is addable */ +"contacts.pathEdit.bulkAdd.empty" = "Não há nós novos para adicionar"; + +// MARK: - Path Discovery Results + +/* Location: PathManagementViewModel.swift - Purpose: Direct path result */ +"contacts.pathDiscovery.direct" = "Direto"; + +/* Location: PathManagementViewModel.swift - Purpose: Hop count result singular */ +"contacts.pathDiscovery.hops.singular" = "1 salto"; + +/* Location: PathManagementViewModel.swift - Purpose: Hop count result plural */ +"contacts.pathDiscovery.hops.plural" = "%d saltos"; + +/* Location: PathManagementViewModel.swift - Purpose: No response message */ +"contacts.pathDiscovery.noResponse" = "O nó remoto não respondeu. Só é possível traçar a rota se o nó tiver os pedidos de telemetria ativados."; + +/* Location: PathManagementViewModel.swift - Purpose: Failed prefix */ +"contacts.pathDiscovery.failed" = "Falha: %@"; + +// MARK: - Saved Paths Sheet + +/* Location: SavedPathsSheet.swift - Purpose: Navigation title */ +"contacts.savedPaths.title" = "Rotas guardadas"; + +/* Location: SavedPathsSheet.swift - Purpose: Delete dialog title */ +"contacts.savedPaths.deleteTitle" = "Eliminar rota"; + +/* Location: SavedPathsSheet.swift - Purpose: Delete dialog message */ +"contacts.savedPaths.deleteMessage" = "Eliminar \"%@\"? Isto remove a rota e todo o histórico de execuções."; + +/* Location: SavedPathsSheet.swift - Purpose: Rename alert title */ +"contacts.savedPaths.renameTitle" = "Renomear rota"; + +/* Location: SavedPathsSheet.swift - Purpose: Rename context menu */ +"contacts.savedPaths.rename" = "Renomear"; + +/* Location: SavedPathsSheet.swift - Purpose: Empty state title */ +"contacts.savedPaths.empty.title" = "Nenhuma rota guardada"; + +/* Location: SavedPathsSheet.swift - Purpose: Empty state description */ +"contacts.savedPaths.empty.description" = "Guarde as rotas depois de as traçar, para as voltar a executar mais tarde."; + +/* Location: SavedPathsSheet.swift - Purpose: Run count singular */ +"contacts.savedPaths.runs.singular" = "1 execução"; + +/* Location: SavedPathsSheet.swift - Purpose: Run count plural */ +"contacts.savedPaths.runs.plural" = "%d execuções"; + +/* Location: SavedPathsSheet.swift - Purpose: Last run label */ +"contacts.savedPaths.lastRun" = "Última: %@"; + +/* Location: SavedPathsSheet.swift - Purpose: Healthy status accessibility */ +"contacts.savedPaths.health.healthy" = "bom"; + +/* Location: SavedPathsSheet.swift - Purpose: Degraded status accessibility */ +"contacts.savedPaths.health.degraded" = "degradado"; + +/* Location: SavedPathsSheet.swift - Purpose: Poor status accessibility */ +"contacts.savedPaths.health.poor" = "fraco"; + +/* Location: SavedPathsSheet.swift - Purpose: Health accessibility label */ +"contacts.savedPaths.healthLabel" = "Estado da rota: %@, taxa de sucesso de %d%%"; + +/* Location: SavedPathsSheet.swift - Purpose: Response times accessibility */ +"contacts.savedPaths.responseTimes" = "Tempos de resposta: média de %dms, %@"; + +/* Location: SavedPathsSheet.swift - Purpose: No response data accessibility */ +"contacts.savedPaths.noResponseData" = "Sem dados de tempos de resposta"; + +/* Location: SavedPathsSheet.swift - Purpose: Trend increasing */ +"contacts.savedPaths.trend.increasing" = "a aumentar"; + +/* Location: SavedPathsSheet.swift - Purpose: Trend decreasing */ +"contacts.savedPaths.trend.decreasing" = "a diminuir"; + +/* Location: SavedPathsSheet.swift - Purpose: Trend stable */ +"contacts.savedPaths.trend.stable" = "estável"; + +// MARK: - Saved Path Detail + +/* Location: SavedPathDetailView.swift - Purpose: Path section header */ +"contacts.pathDetail.path" = "Rota"; + +/* Location: SavedPathDetailView.swift - Purpose: Performance section header */ +"contacts.pathDetail.performance" = "Desempenho"; + +/* Location: SavedPathDetailView.swift - Purpose: Chart Y axis label */ +"contacts.pathDetail.roundTripMs" = "Ida e volta (ms)"; + +/* Location: SavedPathDetailView.swift - Purpose: Average stat label */ +"contacts.pathDetail.avg" = "Média"; + +/* Location: SavedPathDetailView.swift - Purpose: Best stat label */ +"contacts.pathDetail.best" = "Melhor"; + +/* Location: SavedPathDetailView.swift - Purpose: Success stat label */ +"contacts.pathDetail.success" = "Sucesso"; + +/* Location: SavedPathDetailView.swift - Purpose: History section header */ +"contacts.pathDetail.history" = "Histórico"; + +/* Location: SavedPathDetailView.swift - Purpose: Failed status */ +"contacts.pathDetail.failed" = "Falhou"; + +/* Location: SavedPathDetailView.swift - Purpose: Run details navigation title */ +"contacts.pathDetail.runDetails" = "Detalhes da execução"; + +/* Location: SavedPathDetailView.swift - Purpose: Overview section header */ +"contacts.pathDetail.overview" = "Visão geral"; + +/* Location: SavedPathDetailView.swift - Purpose: Date label */ +"contacts.pathDetail.date" = "Data"; + +/* Location: SavedPathDetailView.swift - Purpose: Round trip label */ +"contacts.pathDetail.roundTrip" = "Ida e volta"; + +/* Location: SavedPathDetailView.swift - Purpose: Status label */ +"contacts.pathDetail.status" = "Estado"; + +/* Location: SavedPathDetailView.swift - Purpose: Per-hop SNR section header */ +"contacts.pathDetail.perHopSNR" = "SNR por salto"; + +/* Location: SavedPathDetailView.swift - Purpose: Hop label */ +"contacts.pathDetail.hop" = "Salto %d"; + +// MARK: - Trace Path View + +/* Location: TracePathView.swift - Purpose: Navigation title */ +"contacts.trace.title" = "Traçar rota"; + +/* Location: TracePathView.swift - Purpose: View mode picker label */ +"contacts.trace.viewMode" = "Modo de visualização"; + +/* Location: TracePathView.swift - Purpose: List view mode */ +"contacts.trace.mode.list" = "Lista"; + +/* Location: TracePathView.swift - Purpose: Map view mode */ +"contacts.trace.mode.map" = "Mapa"; + +/* Location: TracePathView.swift - Purpose: Saved toolbar button */ +"contacts.trace.saved" = "Guardados"; + +/* Location: TracePathView.swift - Purpose: Clear path dialog title */ +"contacts.trace.clearPath" = "Limpar rota"; + +/* Location: TracePathView.swift - Purpose: Clear path dialog message */ +"contacts.trace.clearPathMessage" = "Remover todos os repetidores da rota?"; + +/* Location: TracePathView.swift - Purpose: Trace failed alert title */ +"contacts.trace.failed" = "Falha ao traçar"; + +/* Location: TracePathView.swift - Purpose: Jump button label */ +"contacts.trace.runBelow" = "Ir para Executar"; + +/* Location: TracePathView.swift - Purpose: Jump button accessibility label */ +"contacts.trace.jumpLabel" = "Ir para o botão Traçar rota"; + +/* Location: TracePathView.swift - Purpose: Jump button accessibility hint */ +"contacts.trace.jumpHint" = "Toque duas vezes para ir até ao fim da rota"; + +// MARK: - Trace Path List View + +/* Location: TracePathListView.swift - Purpose: Empty path instruction */ +"contacts.trace.list.emptyPath" = "Adicione um salto para começar a montar a rota."; + +/* Location: TracePathListView.swift - Purpose: Round trip path section header */ +"contacts.trace.list.roundTripPath" = "Rota de ida e volta"; + +/* Location: TracePathListView.swift - Purpose: Auto return toggle label */ +"contacts.trace.list.autoReturn" = "Rota de regresso automático"; + +/* Location: TracePathListView.swift - Purpose: Auto return toggle description */ +"contacts.trace.list.autoReturnDescription" = "Espelhar a rota de saída no regresso"; + +/* Location: TracePathListView.swift - Purpose: Batch trace toggle label */ +"contacts.trace.list.batchTrace" = "Traçar em lote"; + +/* Location: TracePathListView.swift - Purpose: Batch trace toggle description */ +"contacts.trace.list.batchTraceDescription" = "Traçar a rota várias vezes e calcular a média dos resultados"; + +/* Location: TracePathListView.swift - Purpose: Traces count label */ +"contacts.trace.list.traces" = "Tentativas:"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size picker label */ +"contacts.trace.list.hashSize" = "Tamanho do hash"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size option, 1 byte per hop */ +"contacts.trace.list.hashSizeOneByte" = "1 byte"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size option, 2 bytes per hop */ +"contacts.trace.list.hashSizeTwoBytes" = "2 bytes"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size option, 4 bytes per hop */ +"contacts.trace.list.hashSizeFourBytes" = "4 bytes"; + +/* Location: PathActionsSectionView.swift - Purpose: Caveat shown for multi-byte trace hash sizes */ +"contacts.trace.list.hashSizeFooter" = "Os repetidores com firmware anterior a 1.11.0 não reencaminham pedidos com um tamanho do hash da rota superior a 1 byte."; + +/* Location: TracePathListView.swift - Purpose: Copy path button */ +"contacts.trace.list.copyPath" = "Copiar rota"; + +/* Location: TracePathListView.swift - Purpose: Range warning footer */ +"contacts.trace.list.rangeWarning" = "É necessário estar ao alcance do último repetidor para receber uma resposta."; + +/* Location: TracePathListView.swift - Purpose: Running trace progress */ +"contacts.trace.list.runningTrace" = "A traçar a rota"; + +/* Location: TracePathListView.swift - Purpose: Running trace with batch count */ +"contacts.trace.list.runningBatch" = "A traçar %d de %d"; + +/* Location: TracePathListView.swift - Purpose: Run trace button */ +"contacts.trace.list.runTrace" = "Traçar"; + +/* Location: TracePathListView.swift - Purpose: Run trace accessibility label */ +"contacts.trace.list.runTraceLabel" = "Traçar rota"; + +/* Location: TracePathListView.swift - Purpose: Batch run accessibility hint */ +"contacts.trace.list.batchHint" = "Toque duas vezes para traçar %d vezes"; + +/* Location: TracePathListView.swift - Purpose: Single run accessibility hint */ +"contacts.trace.list.singleHint" = "Toque duas vezes para traçar a rota"; + +/* Location: TracePathListView.swift - Purpose: Running accessibility label */ +"contacts.trace.list.runningLabel" = "A traçar a rota, aguarde"; + +/* Location: TracePathListView.swift - Purpose: Running batch accessibility label */ +"contacts.trace.list.runningBatchLabel" = "A traçar %d de %d"; + +/* Location: TracePathListView.swift - Purpose: Hop row accessibility label */ +"contacts.trace.list.hopLabel" = "Salto %d: %@"; + +/* Location: TracePathListView.swift - Purpose: Hop row accessibility hint */ +"contacts.trace.list.hopHint" = "Deslize para a esquerda para eliminar; use o puxador para reordenar"; + +// MARK: - Trace Path List Accessibility + +/* Location: TracePathListView.swift - Accessibility hint when trace is running */ +"contacts.trace.list.runningHint" = "A traçar a rota"; + +// MARK: - Trace Path Cluster View + +/* Location: TracePathClusterView.swift - Accessibility label for cluster annotation - %d is count */ +/* Location: TracePathClusterView.swift - Accessibility hint for cluster annotation */ +// MARK: - Trace Path Repeater Pin View + +/* Location: TracePathRepeaterPinView.swift - Accessibility label for repeater in path - %1$@ is name, %2$d is hop number */ +/* Location: TracePathRepeaterPinView.swift - Accessibility hint for removable last hop */ +/* Location: TracePathRepeaterPinView.swift - Accessibility hint for non-removable hop */ +/* Location: TracePathRepeaterPinView.swift - Accessibility label for available repeater - %@ is name */ +/* Location: TracePathRepeaterPinView.swift - Accessibility hint for adding repeater to path */ +// MARK: - Trace Path Map View + +/* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ +"contacts.trace.map.hops" = "%d saltos"; + +/* Location: TracePathMapView.swift - Purpose: Clear button */ +"contacts.trace.map.clear" = "Limpar"; + +/* Location: TracePathMapView.swift - Purpose: Hide labels accessibility */ +/* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centrar na rota"; + +/* Location: TracePathMapView.swift - Purpose: Save path alert title */ +"contacts.trace.map.saveTitle" = "Guardar rota"; + +/* Location: TracePathMapView.swift - Purpose: Save path alert message */ +"contacts.trace.map.saveMessage" = "Introduza um nome para esta rota"; + +/* Location: TracePathMapView.swift - Purpose: Path name placeholder */ +"contacts.trace.map.pathName" = "Nome da rota"; + +/* Location: TracePathMapView.swift - Purpose: Path saved alert title */ +"contacts.trace.map.savedTitle" = "Rota guardada"; + +/* Location: TracePathMapView.swift - Purpose: Path saved alert message */ +"contacts.trace.map.savedMessage" = "A rota foi guardada."; + +/* Location: TracePathMapView.swift - Purpose: Save failed alert title */ +"contacts.trace.map.saveFailedTitle" = "Falha ao guardar"; + +/* Location: TracePathMapView.swift - Purpose: Save failed alert message */ +"contacts.trace.map.saveFailedMessage" = "Não foi possível guardar a rota. Tente novamente."; + +/* Location: TracePathMapView.swift - Purpose: View results button */ +"contacts.trace.map.viewResults" = "Resultados"; + +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Rota"; + +// MARK: - Trace Results Sheet + +/* Location: TraceResultsSheet.swift - Purpose: Navigation title */ +"contacts.results.title" = "Resultados"; + +/* Location: TraceResultsSheet.swift - Purpose: Dismiss button */ +"contacts.results.dismiss" = "Fechar"; + +/* Location: TraceResultsSheet.swift - Purpose: Batch success count */ +"contacts.results.batchSuccess" = "%d de %d bem-sucedidos (%d%%)"; + +/* Location: TraceResultsSheet.swift - Purpose: Batch progress */ +"contacts.results.batchProgress" = "A traçar %d de %d..."; + +/* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ +"contacts.results.batchCompleteLabel" = "Lote concluído: %d de %d tentativas bem-sucedidas (%d%%)"; + +/* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ +"contacts.results.batchProgressLabel" = "Progresso do lote: %d de %d"; + +/* Location: TraceResultsSheet.swift - Purpose: Average round trip label */ +"contacts.results.avgRoundTrip" = "Ida e volta média"; + +/* Location: TraceResultsSheet.swift - Purpose: Average RTT accessibility */ +"contacts.results.avgRTTLabel" = "Ida e volta média: %d milissegundos, intervalo de %d a %d"; + +/* Location: TraceResultsSheet.swift - Purpose: Comparison text */ +"contacts.results.comparison" = "vs. %d ms em %@"; + +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Aumentou"; +"contacts.results.comparison.decreased" = "Diminuiu"; + +/* Location: TraceResultsSheet.swift - Purpose: View runs link */ +"contacts.results.viewRuns" = "Ver %d execuções"; + +/* Location: TraceResultsSheet.swift - Purpose: Total distance label */ +"contacts.results.totalDistance" = "Distância total"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance unavailable */ +"contacts.results.unavailable" = "Indisponível"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info button accessibility */ +"contacts.results.distanceInfo" = "Info de distância"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance unavailable accessibility */ +"contacts.results.distanceUnavailableLabel" = "Distância indisponível"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info hint */ +"contacts.results.distanceInfoHint" = "Toque duas vezes para ver detalhes sobre localizações em falta"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info navigation title (unavailable) */ +"contacts.results.distanceInfoTitle" = "Distância indisponível"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info navigation title (partial) */ +"contacts.results.distanceInfoTitlePartial" = "Informações de distância"; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance explanation */ +"contacts.results.partialDistanceExplanation" = "A distância mostrada é apenas entre repetidores. A distância do dispositivo até ao primeiro repetidor não está incluída porque a localização do dispositivo está indisponível."; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance section header */ +"contacts.results.partialDistanceHeader" = "Distância parcial"; + +/* Location: TraceResultsSheet.swift - Purpose: Full path tip */ +"contacts.results.fullPathTip" = "Ative os serviços de localização ou defina manualmente a localização do dispositivo, para ver a distância completa da rota."; + +/* Location: TraceResultsSheet.swift - Purpose: Full path section header */ +"contacts.results.fullPathHeader" = "Para incluir a rota completa"; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance accessibility label */ +"contacts.results.partialDistanceLabel" = "Distância parcial"; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance accessibility hint */ +"contacts.results.partialDistanceHint" = "Toque duas vezes para saber porque é que a localização do dispositivo está excluída"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance needs repeaters message */ +"contacts.results.needsRepeaters" = "O cálculo da distância exige pelo menos 2 repetidores na rota."; + +/* Location: TraceResultsSheet.swift - Purpose: Distance error message */ +"contacts.results.distanceError" = "Não é possível calcular a distância. Todos os repetidores têm coordenadas, mas ocorreu um erro."; + +/* Location: TraceResultsSheet.swift - Purpose: Distance missing locations message */ +"contacts.results.missingLocations" = "Não é possível calcular a distância porque os seguintes repetidores não têm coordenadas de localização definidas."; + +/* Location: TraceResultsSheet.swift - Purpose: Repeaters without locations section */ +"contacts.results.repeatersWithoutLocations" = "Repetidores sem localização"; + +/* Location: TraceResultsSheet.swift - Purpose: Save path button */ +"contacts.results.savePath" = "Guardar rota"; + +// MARK: - Trace Result Hop Row + +/* Location: TraceResultsSheet.swift - Purpose: My Device placeholder */ +"contacts.results.hop.myDevice" = "O meu dispositivo"; + +/* Location: TraceResultsSheet.swift - Purpose: Started trace label */ +"contacts.results.hop.started" = "Início"; + +/* Location: TraceResultsSheet.swift - Purpose: Received response label */ +"contacts.results.hop.received" = "Resposta recebida"; + +/* Location: TraceResultsSheet.swift - Purpose: Repeated label */ +"contacts.results.hop.repeated" = "Retransmitido"; + +/* Location: TraceResultsSheet.swift - Purpose: Average SNR display */ +"contacts.results.hop.avgSNR" = "SNR médio: %@ dB (%@ – %@)"; + +/* Location: TraceResultsSheet.swift - Purpose: SNR display */ +"contacts.results.hop.snr" = "SNR: %@ dB"; + +/* Location: TraceResultsSheet.swift - Purpose: Average SNR accessibility */ +"contacts.results.hop.avgSNRLabel" = "Relação sinal/ruído média: %@ decibéis, intervalo de %@ a %@"; + +// MARK: - Trace ViewModel Error Messages + +/* Location: TracePathViewModel.swift - Purpose: No response error */ +"contacts.trace.error.noResponse" = "Nenhuma resposta recebida"; + +/* Location: TracePathViewModel.swift - Purpose: All traces failed error */ +"contacts.trace.error.allFailed" = "Falharam as %d tentativas"; + +/* Location: TracePathViewModel.swift - Purpose: Send failed error */ +"contacts.trace.error.sendFailed" = "Falha ao enviar o pacote"; + +// MARK: - Stats Badge Accessibility + +/* Location: StatsBadgeView.swift - Purpose: Distance and signal accessibility */ +// MARK: - Contact ViewModel Errors + +/* Location: ContactsViewModel.swift - Purpose: Delete requires connection error */ +"contacts.viewModel.connectToDelete" = "Ligue o dispositivo para eliminar nós"; + +/* Location: ContactsViewModel.swift - Purpose: Delete radio command timed out error */ +"contacts.viewModel.removeTimedOut" = "A eliminação do nó expirou. Tente novamente."; + +// MARK: - Path Management ViewModel Errors + +/* Location: PathManagementViewModel.swift - Purpose: Save path error prefix */ +"contacts.pathManagement.error.saveFailed" = "Falha ao guardar a rota: %@"; + +/* Location: PathManagementViewModel.swift - Purpose: Reset path error prefix */ +"contacts.pathManagement.error.resetFailed" = "Falha ao repor a rota: %@"; + +/* Location: PathManagementViewModel.swift - Purpose: Set path error prefix */ +"contacts.pathManagement.error.setFailed" = "Falha ao definir a rota: %@"; + +/* Location: PathManagementViewModel.swift - Purpose: Shown when a stored path's hop can't be resized to the device's current hash size */ +"contacts.pathManagement.error.hopResizeRequired" = "O tamanho do hash da rota mudou. Remova e volte a adicionar os saltos para guardar."; + +/* Location: PathManagementViewModel.swift - Purpose: Shown when the existing path has more hops than the current hash mode supports */ +"contacts.pathManagement.error.tooManyHops" = "Demasiados saltos. O modo de hash atual suporta no máximo %d."; + +// MARK: - Code Input Result + +/* Location: TracePathViewModel.swift - Purpose: Invalid format error */ +"contacts.codeInput.error.invalidFormat" = "Formato inválido: %@"; + +/* Location: TracePathViewModel.swift - Purpose: Not found error */ +"contacts.codeInput.error.notFound" = "%@ não encontrado"; + +/* Location: TracePathViewModel.swift - Purpose: Already in path error */ +"contacts.codeInput.error.alreadyInPath" = "%@ já está na rota"; + +// MARK: - Path Name Generation + +/* Location: TracePathViewModel.swift - Purpose: Default path name prefix for hash-only paths */ +"contacts.pathName.prefix" = "Rota %@"; + +/* Location: TracePathViewModel.swift - Purpose: Path name with two endpoints */ +"contacts.pathName.twoEndpoints" = "%@ → %@"; + +/* Location: TracePathViewModel.swift - Purpose: Path name with multiple endpoints (abbreviated) */ +"contacts.pathName.multipleEndpoints" = "%@ → ... → %@"; diff --git a/MC1/Resources/Localization/pt.lproj/Localizable.strings b/MC1/Resources/Localization/pt.lproj/Localizable.strings new file mode 100644 index 000000000..6b581f2a6 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Localizable.strings @@ -0,0 +1,628 @@ +/* + Localizable.strings + MC1 + + European Portuguese (pt-PT) translation of common and shared strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Generic Buttons + +/* Standard confirmation button for dialogs */ +"common.ok" = "OK"; + +/* Standard cancel button for dialogs and sheets */ +"common.cancel" = "Cancelar"; + +/* Standard done button for completing an action */ +"common.done" = "Concluído"; + +/* Standard save button for persisting changes */ +"common.save" = "Guardar"; + +/* Standard delete button for removing items */ +"common.delete" = "Eliminar"; + +/* Standard edit button for entering edit mode */ +"common.edit" = "Editar"; + +/* Standard close button for dismissing views */ +/* Button to retry a failed operation */ +"common.tryAgain" = "Tentar novamente"; + +/* Error label when content fails to load */ +"common.error.failedToLoad" = "Falha ao carregar"; + +/* Network error with underlying error description - %@ is the error description */ +"common.error.networkError" = "Erro de rede: %@"; + +/* Invalid response from API */ +"common.error.invalidResponse" = "Resposta inválida da API de altitude"; + +/* API error with message - %@ is the error message */ +"common.error.apiError" = "Erro de API: %@"; + +/* No data returned from API */ +"common.error.noElevationData" = "Não foram devolvidos dados de altitude"; + +/* Rate limited by elevation API */ +"common.error.rateLimited" = "Demasiados pedidos. Tente novamente dentro de instantes."; + +/* Standard remove button for removing items from a list or group */ +// MARK: - Connection Status + +/* Location: SyncingPillView.swift - Status shown when syncing data */ +"common.status.syncing" = "A sincronizar"; + +/* Location: SyncingPillView.swift - Status shown when connecting to device */ +"common.status.connecting" = "A ligar"; + +/* Location: SyncingPillView.swift - Status shown when device is ready */ +"common.status.ready" = "Pronto"; + +/* Location: SyncingPillView.swift - Status shown when device is disconnected */ +"common.status.disconnected" = "Desligado"; + +// MARK: - Tab Bar + +/* Tab bar title for the messaging/conversations screen */ +"tabs.chats" = "Conversas"; + +/* VoiceOver value announcing the unread message count on the Chats sidebar icon. %d is the count. */ +"tabs.chatsUnreadAccessibilityValue" = "%d por ler"; + +/* Tab bar title for the nodes/contacts list screen */ +"tabs.nodes" = "Nós"; + +/* Tab bar title for the map screen showing node locations */ +"tabs.map" = "Mapa"; + +/* Tab bar title for the tools/utilities screen */ +"tabs.tools" = "Ferramentas"; + +/* Tab bar title for the app settings screen */ +"tabs.settings" = "Definições"; + +// MARK: - Connection Alerts + +/* Alert title when device connection fails */ +"alert.connectionFailed.title" = "Falha na ligação"; + +/* Alert title when connection cannot be established */ +"alert.couldNotConnect.title" = "Não foi possível ligar"; + +/* Alert title when device pairing fails (e.g., wrong PIN) */ +"alert.pairingFailed.title" = "Não foi possível emparelhar"; + +/* Default message when device connection fails */ +"alert.connectionFailed.defaultMessage" = "Não foi possível ligar ao dispositivo."; + +/* Message suggesting another app may be connected to the device */ +"alert.couldNotConnect.otherAppMessage" = "Certifique-se de que nenhuma app está ligada ao dispositivo e tente novamente."; + +/* Button to remove failed pairing and retry connection */ +"alert.connectionFailed.removeAndRetry" = "Remover e tentar novamente"; + +/* VoiceOver label for the destructive Remove and Try Again button — clarifies that "remove" means removing the device pairing */ +"accessibility.alert.connectionFailed.removeAndRetry" = "Remover o emparelhamento do dispositivo e tentar novamente"; + +// MARK: - Accessibility + +/* Location: SyncingPillView.swift - Accessibility hint for disconnected state */ +"common.accessibility.connectHint" = "Toque duas vezes para ligar o dispositivo"; + +/* VoiceOver announcement when viewing cached data while disconnected from device */ +/* Location: View+RadioDisabled.swift - VoiceOver hint when a control is disabled because the radio is not connected */ +"accessibility.requiresRadioConnection" = "Requer ligação de rádio"; + +/* Accessibility value for toggle in On state */ +"accessibility.on" = "Ativado"; + +/* Accessibility value for toggle in Off state */ +"accessibility.off" = "Desativado"; + +// MARK: - Node Types + +/* Node type for a person or contact */ +/* Node type for a mesh network repeater device */ +/* Node type for a group chat room */ +// MARK: - Permission Levels + +/* Permission level with limited access */ +/* Permission level with standard access */ +/* Permission level with full administrative access */ +// MARK: - Reaction Notifications + +/* Notification body when someone reacts to your message - %1$@ is the emoji, %2$@ is the message preview */ +"notifications.reaction.body" = "Reagiu %1$@ à sua mensagem: \"%2$@\""; + +// MARK: - Discovery Notifications + +/* Notification title when a new contact is discovered on the mesh network */ +"notifications.discovery.contact" = "Novo contacto descoberto"; + +/* Notification title when a new repeater node is discovered on the mesh network */ +"notifications.discovery.repeater" = "Novo repetidor descoberto"; + +/* Notification title when a new room is discovered on the mesh network */ +"notifications.discovery.room" = "Nova sala descoberta"; + +// MARK: - Notification Actions + +/* Notification action button to reply to a message */ +"notifications.action.reply" = "Responder"; + +/* Notification action button to send a quick reply */ +"notifications.action.send" = "Enviar"; + +/* Notification action placeholder for quick reply text input */ +"notifications.action.messagePlaceholder" = "Mensagem..."; + +/* Notification action button to mark a message as read */ +"notifications.action.markAsRead" = "Marcar como lida"; + +// MARK: - Low Battery Notifications + +/* Notification title for low battery warning */ +"notifications.lowBattery.title" = "Bateria fraca"; + +/* Notification body for low battery warning - %1$@ is device name, %2$d is battery percentage */ +"notifications.lowBattery.body" = "A bateria de %1$@ está a %2$d%%"; + +/* Notification title when a quick reply fails to send */ +"notifications.quickReplyFailed.title" = "Mensagem não enviada"; + +/* Notification body when a quick reply fails - %@ is the contact or channel name */ +"notifications.quickReplyFailed.body" = "Não foi possível enviar a resposta para %@."; + +/* Fallback display name for a discovered contact with no advertised name */ +"notifications.discovery.unknownContact" = "Contacto desconhecido"; + +// MARK: - Connection VoiceOver Announcements + +/* VoiceOver announcement when sync fails and device is disconnecting */ +"accessibility.connection.syncFailedDisconnecting" = "Falha na sincronização. A desligar."; + +/* VoiceOver announcement when device connection is lost */ +"accessibility.connection.deviceConnectionLost" = "Ligação ao dispositivo perdida"; + +/* VoiceOver announcement when device reconnects */ +"accessibility.connection.deviceReconnected" = "Ligação restabelecida"; + +/* VoiceOver signal-strength descriptors for the device picker signal bars */ +"accessibility.signalStrength.weak" = "Sinal fraco"; +"accessibility.signalStrength.medium" = "Sinal médio"; +"accessibility.signalStrength.strong" = "Sinal forte"; + +// MARK: - Status Pill + +/* Status pill message when sync has failed */ +"statusPill.syncFailed" = "Falha na sincronização"; + +// MARK: - Error Messages + +/* Location: MeshCoreError+UserFacingMessage.swift - Session operation timed out */ +"error.meshCore.timeout" = "A operação expirou. Tente novamente."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Device response could not be parsed - %@ is the parse failure detail */ +"error.meshCore.parseError" = "Falha ao analisar a resposta do dispositivo: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - No active connection to the radio */ +"error.meshCore.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - A command failed on the device - %@ is the failure reason */ +"error.meshCore.commandFailed" = "O comando falhou: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Unexpected device response - %1$@ is the expected response, %2$@ is the received response */ +"error.meshCore.invalidResponse" = "Resposta inesperada do dispositivo (esperado %1$@, recebido %2$@)."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Contact missing from the radio */ +"error.meshCore.contactNotFound" = "Contacto não encontrado no dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Payload exceeds device limit - %1$lld is the actual size in bytes, %2$lld is the maximum */ +"error.meshCore.dataTooLarge" = "Dados demasiado grandes (%1$lld bytes, o máximo é %2$lld)."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Cryptographic signing failed - %@ is the failure reason */ +"error.meshCore.signingFailed" = "Falha na assinatura: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Invalid input - %@ is the validation detail */ +"error.meshCore.invalidInput" = "Entrada inválida: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Unknown error - %@ is the error detail */ +"error.meshCore.unknown" = "Ocorreu um erro desconhecido: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Bluetooth hardware unavailable */ +"error.meshCore.bluetoothUnavailable" = "O Bluetooth não está disponível neste dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Bluetooth permission denied */ +"error.meshCore.bluetoothUnauthorized" = "É necessária permissão de Bluetooth. Ative-a em Definições."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Bluetooth radio is off */ +"error.meshCore.bluetoothPoweredOff" = "O Bluetooth está desligado. Ative o Bluetooth para ligar."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Connection lost with detail - %@ is the underlying error message */ +"error.meshCore.connectionLost" = "A ligação ao dispositivo foi perdida: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Connection lost without further detail */ +"error.meshCore.connectionLostNoDetail" = "A ligação ao dispositivo foi perdida."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Session not started */ +"error.meshCore.sessionNotStarted" = "A sessão não foi iniciada."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Feature disabled in device firmware */ +"error.meshCore.featureDisabled" = "Esta funcionalidade está desativada no dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: command unsupported */ +"error.device.unsupportedCommand" = "Comando não suportado pelo firmware do dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: item not found */ +"error.device.notFound" = "Item não encontrado no dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: storage full */ +"error.device.storageFull" = "O armazenamento do dispositivo está cheio."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: invalid state */ +"error.device.invalidState" = "O dispositivo está num estado inválido para esta operação."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: file system error */ +"error.device.fileSystem" = "Erro no sistema de ficheiros do dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: invalid parameter */ +"error.device.invalidParameter" = "Parâmetro inválido enviado ao dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Firmware error code outside the known range - %lld is the raw code */ +"error.device.unknown" = "Erro do dispositivo (código %lld)."; + +/* Location: TimeoutError+UserFacingMessage.swift - Generic timeout for async operations */ +"error.timeout.operationTimedOut" = "A operação expirou. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth hardware unavailable */ +"error.ble.bluetoothUnavailable" = "O Bluetooth não está disponível neste dispositivo."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth permission denied */ +"error.ble.bluetoothUnauthorized" = "É necessária permissão de Bluetooth. Ative-a em Definições."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth radio is off */ +"error.ble.bluetoothPoweredOff" = "O Bluetooth está desligado. Ative o Bluetooth para ligar."; + +/* Location: BLEError+UserFacingMessage.swift - Scanned device could not be found */ +"error.ble.deviceNotFound" = "Dispositivo não encontrado. Certifique-se de que está ligado e por perto."; + +/* Location: BLEError+UserFacingMessage.swift - BLE connection failed - %@ is the failure detail */ +"error.ble.connectionFailed" = "Falha na ligação: %@"; + +/* Location: BLEError+UserFacingMessage.swift - BLE connection attempt timed out */ +"error.ble.connectionTimeout" = "A ligação expirou. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - No BLE device connected */ +"error.ble.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: BLEError+UserFacingMessage.swift - Required BLE characteristic missing */ +"error.ble.characteristicNotFound" = "Não foi possível comunicar com o dispositivo. Tente restabelecer a ligação."; + +/* Location: BLEError+UserFacingMessage.swift - BLE write failed - %@ is the failure detail */ +"error.ble.writeError" = "Falha ao enviar dados: %@"; + +/* Location: BLEError+UserFacingMessage.swift - Malformed BLE response */ +"error.ble.invalidResponse" = "Resposta inválida do dispositivo. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - BLE operation timed out */ +"error.ble.operationTimeout" = "A operação expirou. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - BLE PIN authentication failed */ +"error.ble.authenticationFailed" = "Falha na autenticação. Verifique o PIN do dispositivo."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth pairing failed - %@ is the failure reason */ +"error.ble.pairingFailed" = "Falha no emparelhamento Bluetooth: %@"; + +/* Location: BLEError+UserFacingMessage.swift - Radio already in use by another app */ +"error.ble.deviceConnectedToOtherApp" = "Este dispositivo está ligado a outra app. Apenas uma app pode usar um rádio mesh de cada vez para evitar problemas de comunicação."; + +/* Location: ConnectionError+UserFacingMessage.swift - Connection failed - %@ is the failure reason */ +"error.connection.connectionFailed" = "Falha na ligação: %@"; + +/* Location: ConnectionError+UserFacingMessage.swift - Device to connect to was not found */ +"error.connection.deviceNotFound" = "Dispositivo não encontrado"; + +/* Location: ConnectionError+UserFacingMessage.swift - No active device connection */ +"error.connection.notConnected" = "Sem ligação ao dispositivo"; + +/* Location: ConnectionError+UserFacingMessage.swift - Post-connect device initialization failed - %@ is the failure reason */ +"error.connection.initializationFailed" = "Falha na inicialização do dispositivo: %@"; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection failed - %@ is the failure reason */ +"error.wifi.connectionFailed" = "Falha na ligação: %@"; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ +"error.wifi.connectionTimeout" = "A ligação expirou. Verifique o hostname ou o endereço IP e certifique-se de que o dispositivo está acessível."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ +"error.wifi.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send failed - %@ is the failure reason */ +"error.wifi.sendFailed" = "Falha ao enviar dados: %@"; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ +"error.wifi.sendTimeout" = "A operação de envio expirou."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Hostname ou endereço IP inválido."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ +"error.wifi.invalidPort" = "Número de porta inválido."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection info missing */ +"error.wifi.notConfigured" = "Ligação não configurada."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - AccessorySetupKit session not active */ +"error.accessorySetup.sessionNotActive" = "O Bluetooth não está pronto. Certifique-se de que o Bluetooth está ativado e tente novamente."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - AccessorySetupKit session invalidated */ +"error.accessorySetup.sessionInvalidated" = "A sessão Bluetooth terminou inesperadamente. Reinicie a app."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - User dismissed the accessory picker */ +"error.accessorySetup.pickerDismissed" = "A seleção do dispositivo foi cancelada."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory picker could not be shown */ +"error.accessorySetup.pickerRestricted" = "Não é possível mostrar o seletor de dispositivos. Verifique se o Bluetooth está ativado, aguarde um momento e tente novamente."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory picker already on screen */ +"error.accessorySetup.pickerAlreadyActive" = "O seletor de dispositivos já está visível."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory pairing failed - %@ is the failure reason */ +"error.accessorySetup.pairingFailed" = "Falha no emparelhamento: %@"; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Picked accessory lacks a Bluetooth identifier */ +"error.accessorySetup.noBluetoothIdentifier" = "O dispositivo selecionado não suporta ligação Bluetooth."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory discovery timed out */ +"error.accessorySetup.discoveryTimeout" = "Nenhum dispositivo encontrado. Certifique-se de que o dispositivo está ligado e por perto."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Connection to picked accessory failed */ +"error.accessorySetup.connectionFailed" = "Não foi possível ligar ao dispositivo. Tente novamente."; + +/* Location: ContactServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.contactService.notConnected" = "Sem ligação ao rádio"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Contact operation message failed to send */ +"error.contactService.sendFailed" = "Falha ao enviar mensagem"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Malformed device response during a contact operation */ +"error.contactService.invalidResponse" = "Resposta inválida do dispositivo"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Contact sync stopped before finishing */ +"error.contactService.syncInterrupted" = "A sincronização foi interrompida"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Contact missing from the radio */ +"error.contactService.contactNotFound" = "Contacto não encontrado no dispositivo"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Radio node list has no free slots */ +"error.contactService.contactTableFull" = "A lista de nós do dispositivo está cheia"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Node advertisement missing or stale, so the node cannot be shared */ +"error.contactService.shareContactUnavailable" = "Não foi possível partilhar o nó. O Advert do nó pode estar em falta ou desatualizado."; + +/* Location: MessageServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.messageService.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Recipient contact missing from the database */ +"error.messageService.contactNotFound" = "Contacto não encontrado."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Target channel missing from the database */ +"error.messageService.channelNotFound" = "Canal não encontrado."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Message send failed */ +"error.messageService.sendFailed" = "Falha no envio."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Recipient cannot receive direct messages */ +"error.messageService.invalidRecipient" = "Não é possível enviar mensagens para este destinatário."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Message text exceeds the maximum length */ +"error.messageService.messageTooLong" = "A mensagem excede o comprimento máximo permitido."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.channelService.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel missing from the database */ +"error.channelService.channelNotFound" = "Canal não encontrado."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel slot index out of range */ +"error.channelService.invalidChannelIndex" = "Índice de canal inválido."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Hashing the channel secret key failed */ +"error.channelService.secretHashingFailed" = "Falha ao gerar o hash da chave secreta do canal."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel save failed - %@ is the failure reason */ +"error.channelService.saveFailed" = "Falha ao guardar o canal: %@"; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel message send failed - %@ is the failure reason */ +"error.channelService.sendFailed" = "Falha no envio: %@"; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel sync already running */ +"error.channelService.syncAlreadyInProgress" = "A sincronização de canais já está em curso."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel sync suspended - %lld is the consecutive failure count */ +"error.channelService.circuitBreakerOpen" = "Sincronização de canais suspensa após %lld falhas consecutivas."; + +/* Location: IntentError.swift - No radio is connected, so retrying alone will not help until one is connected */ +"error.intent.notConnected" = "Nenhum rádio está ligado. Ligue o rádio em MeshCore One e tente novamente."; + +/* Location: IntentError.swift - No single valid recipient was chosen (none, both, or a recipient that cannot receive messages) */ +"error.intent.invalidRecipient" = "Escolha um único destinatário: um contacto ou um canal que possa receber mensagens."; + +/* Location: IntentError.swift - The message text exceeds the maximum length the radio can send */ +"error.intent.messageTooLong" = "A mensagem é demasiado longa para enviar."; + +/* Location: IntentError.swift - Persisting the message to the send queue failed, so it was never queued */ +"error.intent.sendFailed" = "A mensagem não pôde ser colocada na fila de envio. Tente novamente."; + +/* Title of the radio-status Control Center control that opens the app; resolved in the app process */ +"Open MeshCore One" = "Abrir MeshCore One"; + +/* Location: ChatSendQueueServiceError+UserFacingMessage.swift - Queueing a message for send failed - %@ is the underlying error message */ +"error.chatSendQueue.persistFailed" = "Falha ao colocar a mensagem na fila de envio: %@"; + +/* Location: ChatSendQueueServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.chatSendQueue.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: MessagePollingError+UserFacingMessage.swift - No active connection to the radio */ +"error.messagePolling.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: MessagePollingError+UserFacingMessage.swift - Fetching queued messages from the radio failed */ +"error.messagePolling.pollingFailed" = "Falha ao obter as mensagens em espera."; + +/* Location: AdvertisementError+UserFacingMessage.swift - No active connection to the radio */ +"error.advertisement.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: AdvertisementError+UserFacingMessage.swift - Self-advertisement broadcast failed */ +"error.advertisement.sendFailed" = "Falha ao enviar o Advert."; + +/* Location: AdvertisementError+UserFacingMessage.swift - Malformed device response to an advertisement command */ +"error.advertisement.invalidResponse" = "Resposta inválida do dispositivo."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - No active connection to the mesh radio */ +"error.remoteNode.notConnected" = "Sem ligação ao dispositivo mesh"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node login failed */ +"error.remoteNode.loginFailed" = "Falha ao iniciar sessão."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Sending to the remote node failed */ +"error.remoteNode.sendFailed" = "Falha no envio."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Malformed response from the remote node */ +"error.remoteNode.invalidResponse" = "Resposta inválida do nó remoto"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node rejected the operation */ +"error.remoteNode.permissionDenied" = "Permissão recusada"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node request timed out */ +"error.remoteNode.timeout" = "O pedido expirou"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - No stored session for the remote node */ +"error.remoteNode.sessionNotFound" = "Sessão do nó remoto não encontrada"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Saved password missing from the keychain */ +"error.remoteNode.passwordNotFound" = "Palavra-passe não encontrada no Porta-chaves"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Keep-alive needs a direct path but the route is flood-routed */ +"error.remoteNode.floodRouted" = "O keep-alive requer uma rota de encaminhamento direto"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Establishing a direct path to the node failed */ +"error.remoteNode.pathDiscoveryFailed" = "Falha ao estabelecer uma rota direta"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ +"error.remoteNode.contactNotFound" = "Contacto não encontrado na base de dados"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "O rádio está sem espaços para contactos. Remova um contacto que já não seja necessário e tente iniciar sessão novamente."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ +"error.remoteNode.cancelled" = "Início de sessão cancelado"; + +/* Location: RoomServerError+UserFacingMessage.swift - No active connection to the radio */ +"error.roomServer.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: RoomServerError+UserFacingMessage.swift - No stored session for the room server */ +"error.roomServer.sessionNotFound" = "Sessão da sala não encontrada."; + +/* Location: RoomServerError+UserFacingMessage.swift - Room message send failed */ +"error.roomServer.sendFailed" = "Falha no envio."; + +/* Location: RoomServerError+UserFacingMessage.swift - Room server rejected the operation */ +"error.roomServer.permissionDenied" = "Permissão recusada."; + +/* Location: RoomServerError+UserFacingMessage.swift - Malformed device response during a room operation */ +"error.roomServer.invalidResponse" = "Resposta inválida do dispositivo."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - No active connection to the radio */ +"error.binaryProtocol.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - Binary request failed to send */ +"error.binaryProtocol.sendFailed" = "Falha ao enviar o pedido."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - Binary request timed out */ +"error.binaryProtocol.timeout" = "O pedido expirou."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - Malformed device response to a binary request */ +"error.binaryProtocol.invalidResponse" = "Resposta inválida do dispositivo."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Device row missing from the database */ +"error.persistence.deviceNotFound" = "Dispositivo não encontrado."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Contact row missing from the database */ +"error.persistence.contactNotFound" = "Contacto não encontrado."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Message row missing from the database */ +"error.persistence.messageNotFound" = "Mensagem não encontrada."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Channel row missing from the database */ +"error.persistence.channelNotFound" = "Canal não encontrado."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Remote node session row missing from the database */ +"error.persistence.remoteNodeSessionNotFound" = "Sessão do nó remoto não encontrada."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Database save failed - %@ is the failure reason */ +"error.persistence.saveFailed" = "Falha ao guardar: %@"; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Database fetch failed - %@ is the failure reason */ +"error.persistence.fetchFailed" = "Falha ao obter: %@"; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Stored data is malformed */ +"error.persistence.invalidData" = "Dados inválidos."; + +/* Location: SyncCoordinatorError+UserFacingMessage.swift - No active connection to the radio */ +"error.syncCoordinator.notConnected" = "Sem ligação ao dispositivo."; + +/* Location: SyncCoordinatorError+UserFacingMessage.swift - Full sync failed - %@ is the failure reason */ +"error.syncCoordinator.syncFailed" = "Falha na sincronização: %@"; + +/* Location: SyncCoordinatorError+UserFacingMessage.swift - A sync is already running */ +"error.syncCoordinator.alreadySyncing" = "Já está a decorrer uma sincronização."; + +/* Location: DeviceServiceError+UserFacingMessage.swift - Device row missing from the database */ +"error.deviceService.deviceNotFound" = "Dispositivo não encontrado"; + +/* Location: DeviceServiceError+UserFacingMessage.swift - Saving device settings failed - %@ is the failure reason */ +"error.deviceService.persistenceFailed" = "Falha ao guardar as definições do dispositivo: %@"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.settings.notConnected" = "Dispositivo não ligado"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - Sending a settings command failed */ +"error.settings.sendFailed" = "Falha ao enviar o comando"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - Malformed device response to a settings command */ +"error.settings.invalidResponse" = "Resposta inválida do dispositivo"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - Setting write-back mismatch - %1$@ is the expected value, %2$@ is the value the device reports */ +"error.settings.verificationFailed" = "A definição não foi guardada. Esperado '%1$@', mas o dispositivo indica '%2$@'."; + +/* Location: SettingsServiceError+UserFacingMessage.swift - GPS write-back mismatch when GPS should be on */ +"error.settings.gpsNotSavedExpectedOn" = "A definição de GPS do dispositivo não foi guardada. Esperado 'Ativado', mas o dispositivo indica 'Desativado'."; + +/* Location: SettingsServiceError+UserFacingMessage.swift - GPS write-back mismatch when GPS should be off */ +"error.settings.gpsNotSavedExpectedOff" = "A definição de GPS do dispositivo não foi guardada. Esperado 'Desativado', mas o dispositivo indica 'Ativado'."; + +/* Location: KeychainError+UserFacingMessage.swift - Encoding a password for keychain storage failed */ +"error.keychain.encodingFailed" = "Falha ao codificar a palavra-passe"; + +/* Location: KeychainError+UserFacingMessage.swift - Keychain write failed - %lld is the OSStatus code */ +"error.keychain.storageFailed" = "Falha ao armazenar a palavra-passe (erro %lld)"; + +/* Location: KeychainError+UserFacingMessage.swift - Keychain read failed - %lld is the OSStatus code */ +"error.keychain.retrievalFailed" = "Falha ao recuperar a palavra-passe (erro %lld)"; + +/* Location: KeychainError+UserFacingMessage.swift - Keychain delete failed - %lld is the OSStatus code */ +"error.keychain.deletionFailed" = "Falha ao eliminar a palavra-passe (erro %lld)"; + +/* Location: KeyGenerationError+UserFacingMessage.swift - Vanity prefix search exhausted its attempt budget */ +"error.keyGeneration.maxAttemptsExceeded" = "Não foi possível gerar uma chave com esse prefixo. Tente outro."; + +/* Location: KeyGenerationError+UserFacingMessage.swift - Requested prefix byte is reserved by firmware */ +"error.keyGeneration.reservedPrefix" = "Esse prefixo está reservado e não pode ser usado."; + +/* Location: KeyGenerationError+UserFacingMessage.swift - System random number generator failed */ +"error.keyGeneration.randomGenerationFailed" = "Falha na geração segura de números aleatórios. Tente novamente."; + +/* Location: KeyGenerationError+UserFacingMessage.swift - Key data is not a valid Ed25519 expanded private key */ +"error.keyGeneration.invalidKey" = "A chave não é uma chave privada Ed25519 válida."; diff --git a/MC1/Resources/Localization/pt.lproj/Localizable.stringsdict b/MC1/Resources/Localization/pt.lproj/Localizable.stringsdict new file mode 100644 index 000000000..c7df8cfd5 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Localizable.stringsdict @@ -0,0 +1,29 @@ + + + + + + error.channelService.circuitBreakerOpen + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + Sincronização de canais suspensa após %lld falha consecutiva. + other + Sincronização de canais suspensa após %lld falhas consecutivas. + + + + diff --git a/MC1/Resources/Localization/pt.lproj/Map.strings b/MC1/Resources/Localization/pt.lproj/Map.strings new file mode 100644 index 000000000..8c7e173f8 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Map.strings @@ -0,0 +1,193 @@ +/* + Map.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the Map feature. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Common + +/* Location: MapView.swift - Purpose: Done button for sheets */ +"map.common.done" = "Concluído"; + +// MARK: - Map Controls + +/* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ +"map.controls.showLabels" = "Mostrar etiquetas"; + +/* Location: MapView.swift - Purpose: Accessibility label for center on all contacts button */ +"map.controls.centerAll" = "Centrar em todos os contactos"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ +"map.controls.centerOnMyLocation" = "Centrar na minha localização"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +"map.controls.mapOptions" = "Opções do mapa"; + +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +"map.controls.lockNorth" = "Norte para cima"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Atualizar contactos"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map filter control */ +"map.controls.filter" = "Filtrar"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility value when filter differs from host defaults */ +"map.controls.filterActive" = "Filtros ativos"; + +// MARK: - Map Style Selection + +/* Location: MapStyleSelection.swift - Purpose: Standard map style option */ +"map.style.standard" = "Padrão"; + +/* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ +"map.style.satellite" = "Satélite"; + +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografia"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Estilo do mapa"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requer ligação de rede"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "Nenhum mapa offline cobre esta área"; + +// MARK: - Contact Detail Sheet + +/* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ +"map.detail.section.contactInfo" = "Informações do contacto"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for contact name */ +"map.detail.name" = "Nome"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for contact type */ +"map.detail.type" = "Tipo"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for favorite status */ +"map.detail.status" = "Estado"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ +"map.detail.favorite" = "Favorito"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ +"map.detail.lastAdvert" = "Último Advert"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Última receção"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ +"map.detail.publicKey" = "Chave pública"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ +"map.detail.section.location" = "Localização"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for latitude coordinate */ +"map.detail.latitude" = "Latitude"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for longitude coordinate */ +"map.detail.longitude" = "Longitude"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Section header for outbound path info */ +"map.detail.section.outboundPath" = "Rota de saída"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for routing type */ +"map.detail.routing" = "Encaminhamento"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Flood routing type value */ +"map.detail.routingFlood" = "Difusão"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for path length */ +"map.detail.pathLength" = "Comprimento da rota"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Path length value with hop count */ +"map.detail.hops" = "%d saltos"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Path length value for single hop */ +"map.detail.hopSingular" = "1 salto"; + +// MARK: - Contact Detail Sheet Actions + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ +"map.detail.action.telemetry" = "Telemetria"; + +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +/* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ +"map.detail.action.telemetryAccessTitle" = "Acesso à telemetria"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Gestão"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ +"map.detail.action.joinRoom" = "Entrar na sala"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to send a message */ +"map.detail.action.sendMessage" = "Enviar mensagem"; + +// MARK: - Contact Type Names + +/* Location: MapView.swift ContactDetailSheet - Purpose: Display name for chat contact type */ +"map.nodeKind.chatContact" = "Contacto de chat"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Display name for repeater type */ +"map.nodeKind.repeater" = "Repetidor"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Display name for room type */ +"map.nodeKind.room" = "Sala"; + +// MARK: - Contact Callout + +/* Location: ContactCalloutContent.swift - Purpose: Button to view contact details */ +"map.callout.details" = "Detalhes"; + +/* Location: ContactCalloutContent.swift - Purpose: Button to send message from callout */ +"map.callout.message" = "Mensagem"; + +/* Location: ContactCalloutContent.swift - Purpose: Display name for contact in callout */ +"map.callout.nodeKind.contact" = "Contacto"; + +/* Location: ContactCalloutContent.swift - Purpose: Display name for repeater in callout */ +"map.callout.nodeKind.repeater" = "Repetidor"; + +/* Location: ContactCalloutContent.swift - Purpose: Display name for room in callout */ +"map.callout.nodeKind.room" = "Sala"; + +/* Location: DiscoveredNodeCalloutContent.swift - Purpose: Add discovered node from callout */ +"map.callout.add" = "Adicionar"; + +/* Location: DiscoveredNodeCalloutContent.swift - Purpose: Label that the pin is discovered, not on the node list */ +"map.callout.discovered" = "Descoberto"; + +/* Location: DiscoveredNodeDetailSheet.swift - Purpose: Navigation title for discovered node detail */ +"map.discoveredDetail.title" = "Nó descoberto"; + +/* Location: DiscoveredNodeDetailSheet.swift - Purpose: Primary action to add discovered node to the radio table */ +"map.discoveredDetail.add" = "Adicionar aos nós"; + +/* Location: DiscoveredNodeCalloutContent.swift - Purpose: Accessibility label for discovered pins */ +"map.pin.accessibility.discovered" = "Descoberto, não está na lista de nós"; + +// MARK: - Contact Annotation + +/* Location: ContactAnnotation.swift - Purpose: Subtitle for favorite contacts */ +/* Location: ContactAnnotation.swift - Purpose: Subtitle for repeater nodes */ +/* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; + +// MARK: - Chat Map Preview + +/* Location: MapPreviewFragmentView.swift - Purpose: Accessibility label for the chat map-location thumbnail */ +"map.preview.accessibilityLabel" = "Pré-visualização da localização no mapa"; + +/* Location: MapPreviewFragmentView.swift - Purpose: Accessibility hint for tapping the chat map-location thumbnail */ +"map.preview.accessibilityHint" = "Abre esta localização no mapa"; + +/* Location: MapPreviewFragmentView.swift - Purpose: Accessibility label for the retry control shown on the failed-thumbnail fallback */ +"map.preview.retryButton.accessibilityLabel" = "Tentar carregar miniatura do mapa novamente"; diff --git a/MC1/Resources/Localization/pt.lproj/Onboarding.strings b/MC1/Resources/Localization/pt.lproj/Onboarding.strings new file mode 100644 index 000000000..4394e29c9 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Onboarding.strings @@ -0,0 +1,325 @@ +/* + Onboarding.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the Onboarding feature. + AI-translated - please verify with native speakers. +*/ + + +// MARK: - Welcome View + +/* Location: WelcomeView.swift - App title displayed on welcome screen */ +"welcome.title" = "MeshCore One"; + +/* Location: WelcomeView.swift - Subtitle describing the app */ +"welcome.subtitle" = "Envie mensagens por uma rede construída pela comunidade, mesmo sem internet."; + +/* Location: WelcomeView.swift - Button to proceed to next onboarding step */ +"welcome.getStarted" = "Começar"; + + +// MARK: - Permissions View + +/* Location: PermissionsView.swift - Screen title for permissions */ +"permissions.title" = "Algumas permissões"; + +/* Location: PermissionsView.swift - Subtitle encouraging notification permission */ +"permissions.subtitle" = "As duas são opcionais. É possível mudar de ideias nas Definições a qualquer momento."; + +/* Location: PermissionsView.swift - Permission card title for notifications */ +"permissions.notifications.title" = "Notificações"; + +/* Location: PermissionsView.swift - Permission card description for notifications */ +"permissions.notifications.description" = "Receba alertas de novas mensagens e bateria fraca, mesmo com a app fechada."; + +/* Location: PermissionsView.swift - Permission card title for location */ +"permissions.location.title" = "Localização"; + +/* Location: PermissionsView.swift - Permission card description for location */ +"permissions.location.description" = "Sugerir predefinições de rádio locais, mostrar a posição no mapa e ordenar contactos por distância."; + +/* Location: PermissionsView.swift - Button to proceed to next step */ +"permissions.continue" = "Continuar"; + +/* Location: PermissionsView.swift - Badge shown for optional permissions */ +"permissions.optional" = "Opcional"; + +/* Location: PermissionsView.swift - Button to open system settings */ +"permissions.openSettings" = "Definições"; + +/* Location: PermissionsView.swift - Button to begin a permission request */ +"permissions.request" = "Continuar"; + +/* Location: PermissionsView.swift - Alert title for location permission */ +"permissions.locationAlert.title" = "Permissão de localização"; + +/* Location: PermissionsView.swift - Alert button to open settings */ +"permissions.locationAlert.openSettings" = "Abrir Definições"; + +/* Location: PermissionsView.swift - Alert message explaining denied location permission */ +"permissions.locationAlert.message" = "A permissão de localização foi recusada anteriormente. Ative-a em Definições para partilhar a localização com os contactos da mesh."; + + +// MARK: - Device Scan View + +/* Location: DeviceScanView.swift - Screen title for device pairing */ +"deviceScan.title" = "Emparelhe o dispositivo"; + +/* Location: DeviceScanView.swift - Subtitle with pairing instructions */ +"deviceScan.subtitle" = "Ligue o rádio, desemparelhe-o de todas as outras apps e dispositivos e toque em Adicionar dispositivo."; + +/* Location: DeviceScanView.swift - Message shown when device is already paired */ +"deviceScan.alreadyPaired" = "O dispositivo já está emparelhado"; + +/* Location: DeviceScanView.swift - "I don't have a device yet" tertiary */ +"deviceScan.noDeviceYet" = "Ainda não tenho um dispositivo"; + +/* Location: DeviceScanView.swift - Button to continue after pairing */ +"deviceScan.continue" = "Continuar"; + +/* Location: DeviceScanView.swift - Button label while connecting */ +"deviceScan.connecting" = "A ligar..."; + +/* Location: DeviceScanView.swift - Button to connect simulator (debug) */ +"deviceScan.connectSimulator" = "Ligar simulador"; + +/* Location: DeviceScanView.swift - Button to continue in demo mode */ +"deviceScan.continueDemo" = "Continuar no modo demo"; + +/* Location: DeviceScanView.swift - Button to add a new device */ +"deviceScan.addDevice" = "Adicionar dispositivo"; + +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Tentar ligar novamente"; + +/* Location: DeviceScanView.swift - Button for troubleshooting */ +"deviceScan.deviceNotAppearing" = "Ajuda"; + +/* Location: DeviceScanView.swift - Button to connect via WiFi */ +"deviceScan.connectViaWifi" = "Ligar via Wi-Fi"; + +/* Location: DeviceScanView.swift - Alert title when demo mode is unlocked */ +"deviceScan.demoModeAlert.title" = "Modo demo desbloqueado"; + +/* Location: DeviceScanView.swift - Alert message for demo mode */ +"deviceScan.demoModeAlert.message" = "Agora é possível continuar sem um dispositivo. Ative ou desative o modo demo em Definições a qualquer momento."; + +/* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ +"deviceScan.error.authenticationFailed" = "O emparelhamento guardado com este rádio deixou de funcionar. Toque em Remover e tentar novamente para emparelhar de novo na app; o rádio pode mostrar um novo código de emparelhamento."; +"deviceScan.error.pinRejected" = "O PIN não foi aceite. Verifique o PIN mostrado no dispositivo e tente novamente. O iOS vai primeiro pedir para confirmar a remoção do emparelhamento anterior."; +"deviceScan.error.connectionFailed" = "Não foi possível ligar ao dispositivo. Tente novamente ou remova-o se o problema continuar."; + +// MARK: - Troubleshooting Sheet + +/* Location: DeviceScanView.swift - Navigation title for troubleshooting sheet */ +"troubleshooting.title" = "Resolução de problemas"; + +/* Location: DeviceScanView.swift - Section header for basic checks */ +"troubleshooting.basicChecks.header" = "Verificações básicas"; + +/* Location: DeviceScanView.swift - Check to ensure device is powered on */ +"troubleshooting.basicChecks.powerOn" = "Confirme que o dispositivo está ligado"; + +/* Location: DeviceScanView.swift - Check to move device closer */ +"troubleshooting.basicChecks.moveCloser" = "Aproxime o dispositivo do telemóvel"; + +/* Location: TroubleshootingSheet.swift - Check that radio isn't connected elsewhere */ +"troubleshooting.basicChecks.notConnectedElsewhere" = "Confirme que o rádio não está ligado a outro telemóvel ou app"; + +/* Location: DeviceScanView.swift - Check to restart the device */ +"troubleshooting.basicChecks.restart" = "Reinicie o dispositivo MeshCore"; + +/* Location: DeviceScanView.swift - Section header for factory reset help */ +"troubleshooting.factoryReset.header" = "Repor o dispositivo para as definições de fábrica?"; + +/* Location: DeviceScanView.swift - Explanation about stale pairings */ +"troubleshooting.factoryReset.explanation" = "Se o dispositivo MeshCore for reposto para as definições de fábrica, o iOS ainda pode ter o emparelhamento antigo. Limpar isto em Definições do sistema permite que o dispositivo volte a aparecer."; + +/* Location: DeviceScanView.swift - Additional explanation about removal confirmation */ +"troubleshooting.factoryReset.confirmationNote" = "Ao tocar abaixo, terá de confirmar a remoção do emparelhamento antigo. Isto é normal: permite que o dispositivo reposto volte a aparecer."; + +/* Location: DeviceScanView.swift - Button to clear previous pairing */ +"troubleshooting.factoryReset.clearPairing" = "Limpar emparelhamento anterior"; + +/* Location: DeviceScanView.swift - Footer when no pairings found */ +"troubleshooting.factoryReset.noPairings" = "Nenhum emparelhamento anterior encontrado."; + +/* Location: DeviceScanView.swift - Footer showing pairing count - uses stringsdict */ +"troubleshooting.factoryReset.pairingsFound" = "%d emparelhamento(s) anterior(es) encontrado(s)."; + +/* Location: DeviceScanView.swift - Section header for system settings info */ +"troubleshooting.systemSettings.header" = "Definições do sistema"; + +/* Location: DeviceScanView.swift - Info about managing accessories */ +"troubleshooting.systemSettings.manageAccessories" = "Também é possível gerir acessórios Bluetooth em:"; + +/* Location: DeviceScanView.swift - Path to accessories in settings */ +"troubleshooting.systemSettings.path" = "Definições → Privacidade e segurança → Acessórios"; + +/* Location: TroubleshootingSheet.swift - Button that opens iOS Settings */ +"troubleshooting.systemSettings.openSettings" = "Abrir Definições"; + + +// MARK: - Still Not Appearing + +/* Location: TroubleshootingSheet.swift - Section header for last-resort flashing steps */ +"troubleshooting.stillNotAppearing.header" = "Ainda não aparece?"; + +/* Location: TroubleshootingSheet.swift - Body text explaining backup and reflash steps */ +"troubleshooting.stillNotAppearing.body" = "Se já tentou tudo acima, faça uma cópia de segurança da configuração do rádio na app MeshCore de Liam Cottle e depois apague e volte a instalar o firmware a partir do computador em https://flasher.meshcore.io"; + + +// MARK: - Mesh Animation View + +/* Location: MeshAnimationView.swift - Accessibility label for mesh visualization */ +"meshAnimation.accessibilityLabel" = "Visualização da rede mesh"; + + +// MARK: - No Device Sheet + +/* Location: NoDeviceSheet.swift - Sheet title */ +"noDevice.sheet.title" = "Dê uma vista de olhos"; + +/* Location: NoDeviceSheet.swift - Sheet body */ +"noDevice.sheet.body" = "É necessário um rádio emparelhado para enviar e receber mensagens. Explore a app por agora e emparelhe a qualquer momento em Definições."; + +/* Location: NoDeviceSheet.swift - Primary CTA */ +"noDevice.sheet.confirm" = "Continuar"; + +/* Location: NoDeviceSheet.swift - Secondary CTA */ +"noDevice.sheet.cancel" = "Cancelar"; + + +// MARK: - Preset Step View + +/* Location: PresetStepView.swift - Title */ +"preset.title" = "Escolha uma predefinição"; + +/* Location: PresetStepView.swift - Subtitle when recommendation exists */ +"preset.subtitle.recommended" = "Recomendada para %@"; + +/* Location: PresetStepView.swift - Subtitle for empty-region fallback */ +"preset.subtitle.locale" = "Escolha uma predefinição para o rádio."; + +/* Location: PresetStepView.swift - Footer help line pointing users to the MeshCore Discord */ +"preset.discordHelp" = "Não sabe que predefinição escolher? Entre no Discord oficial do MeshCore e pergunte! [https://meshcore.gg](https://meshcore.gg)"; + +/* Location: PresetStepView.swift - Apply CTA "Use %@" */ +"preset.use" = "Usar %@"; + +/* Location: PresetStepView.swift - Already-configured title */ +"preset.alreadyConfigured.title" = "Já configurado"; + +/* Location: PresetStepView.swift - Already-configured subtitle */ +"preset.alreadyConfigured.subtitle" = "O rádio já está em %@, a predefinição recomendada para %@."; + +/* Location: PresetStepView.swift - Already-configured primary CTA */ +"preset.alreadyConfigured.done" = "Concluído"; + +/* Location: PresetStepView.swift - Already-configured secondary link */ +"preset.alreadyConfigured.choose" = "Escolher outra predefinição"; + +/* Location: PresetStepView.apply(id:) - Error shown when user taps Apply before services finished wiring */ +"preset.error.notConnected" = "Ligue o dispositivo para aplicar esta predefinição."; + +/* Location: PresetStepView.swift - Generic Continue label used when no preset is selected */ +"preset.continue" = "Continuar"; + +/* Location: PresetStepView.swift - VoiceOver hint for tapping a preset card */ +"preset.row.accessibilityHint" = "Seleciona esta predefinição."; + +// MARK: - WiFi Connection Sheet + +/* Location: WiFiConnectionSheet.swift - Navigation title */ +"wifiConnection.title" = "Ligar via Wi-Fi"; + +/* Location: WiFiConnectionSheet.swift - Section header for connection details */ +"wifiConnection.connectionDetails.header" = "Detalhes da ligação"; + +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostname ou IP"; + +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Limpar hostname ou IP"; + +/* Location: WiFiConnectionSheet.swift - Placeholder for port field */ +"wifiConnection.port.placeholder" = "Porta"; + +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear port button */ +"wifiConnection.port.clearAccessibility" = "Limpar porta"; + +/* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ +"wifiConnection.connectionDetails.footer" = "Introduza o hostname ou o endereço IP do dispositivo MeshCore. A porta predefinida é 5000."; + +/* Location: WiFiConnectionSheet.swift - Button label while connecting */ +"wifiConnection.connecting" = "A ligar..."; + +/* Location: WiFiConnectionSheet.swift - Button to initiate connection */ +"wifiConnection.connect" = "Ligar"; + +/* Location: WiFiConnectionSheet.swift - Error message for invalid port */ +"wifiConnection.error.invalidPort" = "Número de porta inválido"; + + +// MARK: - Region Picker View + +/* Location: RegionPickerView.swift - Country picker label */ +"region.country" = "País"; + +/* Location: RegionPickerView.swift - State/Province picker label */ +"region.administrativeArea" = "Estado / Província"; + +/* Location: RegionPickerView.swift - Continue CTA in manual picker */ +"region.continue" = "Continuar"; + +/* Location: RegionStepView.swift - Title (both states) */ +"region.title" = "Escolha a região"; + +/* Location: RegionStepView.swift - Subtitle for the region step */ +"region.subtitle" = "Só aparecem as predefinições que funcionam na sua área."; + +/* Location: RegionStepView.swift - "Detected" tag */ +"region.detected.tag" = "Detetada"; + +/* Location: RegionStepView.swift - Caption "From your location" */ +"region.detected.source" = "Da sua localização"; + +/* Location: RegionStepView.swift - Primary CTA in detected state */ +"region.useThisRegion" = "Usar esta região"; + +/* Location: RegionStepView.swift - "Choose another" link */ +"region.chooseAnother" = "Escolher outra"; + +/* Location: RegionStepView.swift - "Use my location" link in manual state */ +"region.useMyLocation" = "Usar a minha localização"; + +/* Location: RegionStepView.swift - "Finding your region…" */ +"region.resolving" = "A procurar a região…"; + +/* Location: RegionStepView.swift - Error shown when user-initiated retry of "Use my location" cannot resolve a region */ +"region.useMyLocation.failure" = "Não foi possível determinar a região a partir da sua localização. Escolha manualmente abaixo."; + + +// MARK: - Device Scanner Sheet (macOS) + +/* Location: DeviceScannerSheet.swift - Navigation title for the macOS BLE scanner */ +"deviceScanner.title" = "Selecionar dispositivo"; + +/* Location: DeviceScannerSheet.swift - Placeholder name for peripherals with no advertised name */ +"deviceScanner.unknownDevice" = "Dispositivo desconhecido"; + +/* Location: DeviceScannerSheet.swift - Empty state shown while scanning for devices */ +"deviceScanner.scanning" = "A procurar dispositivos…"; + +/* Location: DeviceScannerSheet.swift - Title shown when Bluetooth is powered off on the macOS scanner */ +"deviceScanner.bluetoothOff.title" = "O Bluetooth está desligado"; + +/* Location: DeviceScannerSheet.swift - Guidance shown when Bluetooth is powered off on the macOS scanner */ +"deviceScanner.bluetoothOff.message" = "Ligue o Bluetooth para procurar dispositivos próximos."; + +/* Location: DeviceScannerSheet.swift - Title shown when the app is not authorized to use Bluetooth */ +"deviceScanner.bluetoothUnauthorized.title" = "Acesso ao Bluetooth necessário"; + +/* Location: DeviceScannerSheet.swift - Guidance shown when the app is not authorized to use Bluetooth */ +"deviceScanner.bluetoothUnauthorized.message" = "Permita o acesso ao Bluetooth em Definições para procurar dispositivos próximos."; diff --git a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings new file mode 100644 index 000000000..791766898 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings @@ -0,0 +1,922 @@ +/* + RemoteNodes.strings + MC1 + + European Portuguese (pt-PT) translation of remote nodes strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Node Authentication Sheet + +/* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ +"remoteNodes.auth.joinRoom" = "Entrar na sala"; + +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Gestão"; + +/* Location: NodeAuthenticationSheet.swift - Cancel button */ +"remoteNodes.auth.cancel" = "Cancelar"; + +/* Location: NodeAuthenticationSheet.swift - Node details section header */ +"remoteNodes.auth.nodeDetails" = "Detalhes do nó"; + +/* Location: NodeAuthenticationSheet.swift - Name label */ +"remoteNodes.auth.name" = "Nome"; + +/* Location: NodeAuthenticationSheet.swift - Type label */ +"remoteNodes.auth.type" = "Tipo"; + +/* Location: NodeAuthenticationSheet.swift - Room type value */ +"remoteNodes.auth.typeRoom" = "Sala"; + +/* Location: NodeAuthenticationSheet.swift - Repeater type value */ +"remoteNodes.auth.typeRepeater" = "Repetidor"; + +/* Location: NodeAuthenticationSheet.swift - Authentication section header */ +"remoteNodes.auth.authentication" = "Autenticação"; + +/* Location: NodeAuthenticationSheet.swift - Password field placeholder */ +"remoteNodes.auth.password" = "Palavra-passe"; + +/* Location: NodeAuthenticationSheet.swift - Remember password toggle */ +"remoteNodes.auth.rememberPassword" = "Memorizar palavra-passe"; + +/* Location: NodeAuthenticationSheet.swift - Error accessibility label prefix */ +"remoteNodes.auth.errorPrefix" = "Erro: %@"; + +/* Location: NodeAuthenticationSheet.swift - Password too long warning for repeaters */ +"remoteNodes.auth.passwordTooLongRepeaters" = "Os repetidores MeshCore só aceitam palavras-passe com até %d caracteres. Os caracteres extra serão ignorados."; + +/* Location: NodeAuthenticationSheet.swift - Password too long warning for rooms */ +"remoteNodes.auth.passwordTooLongRooms" = "As salas MeshCore só aceitam palavras-passe com até %d caracteres. Os caracteres extra serão ignorados."; + +/* Location: NodeAuthenticationSheet.swift - Countdown text showing seconds remaining */ +"remoteNodes.auth.secondsRemaining" = "Até %d segundos restantes"; + +/* Location: NodeAuthenticationSheet.swift - Connect button */ +"remoteNodes.auth.connect" = "Ligar"; + +/* Location: NodeAuthenticationSheet.swift - Path section header */ +"remoteNodes.auth.path" = "Rota"; + +/* Location: NodeAuthenticationSheet.swift - Flood routing toggle */ +"remoteNodes.auth.floodRouting" = "Encaminhamento por difusão"; +"remoteNodes.auth.floodRetryStatus" = "Sem resposta pela rota conhecida. A tentar novamente com encaminhamento por difusão."; +"remoteNodes.auth.floodFooter" = "O encaminhamento por difusão é mais lento e usa mais tempo de antena da rede. Utilize-o quando a rota conhecida não funcionar a partir da localização atual. Uma nova rota é aprendida automaticamente após iniciar sessão."; + +/* Location: NodeAuthenticationSheet.swift - Path section footer when stored path exists */ +"remoteNodes.auth.pathFooter" = "Utilize a rota conhecida até este nó, ou mude para o encaminhamento por difusão e deixe que a rede encontre outra."; + +/* Location: NodeAuthenticationSheet.swift - Path hop whose repeater is not known */ +"remoteNodes.auth.pathHopUnknown" = ""; + +/* Location: NodeAuthenticationSheet.swift - Label when no route is set */ +"remoteNodes.auth.noRouteSet" = "Nenhuma rota definida"; + +/* Location: NodeAuthenticationSheet.swift - Path section footer when no route is set */ +"remoteNodes.auth.noRouteFooter" = "Nenhuma rota conhecida até este nó. A rede irá encontrar uma automaticamente."; + +/* Location: NodeAuthenticationSheet.swift - Accessibility announcement for countdown */ +"remoteNodes.auth.secondsRemainingAnnouncement" = "%d segundos restantes"; + +// MARK: - Repeater Settings + +/* Location: RepeaterSettingsView.swift - Navigation title */ +"remoteNodes.settings.title" = "Definições do repetidor"; + +/* Location: RepeaterSettingsView.swift - Done button (used in multiple places) */ +"remoteNodes.settings.done" = "Concluído"; + +/* Location: NodeSettingsView.swift - Settings tab label */ +"remoteNodes.settings.tab.settings" = "Definições"; + +/* Location: NodeSettingsView.swift - CLI tab label */ +"remoteNodes.settings.tab.cli" = "CLI"; + +/* Location: NodeSettingsView.swift - Telemetry tab label */ +"remoteNodes.settings.tab.telemetry" = "Telemetria"; + +/* Location: NodeSettingsView.swift - View mode picker label */ +"remoteNodes.settings.tab.picker" = "Modo de visualização"; + +/* Location: RepeaterSettingsView.swift - Success alert title */ +"remoteNodes.settings.success" = "Sucesso"; + +/* Location: RepeaterSettingsView.swift - OK button */ +"remoteNodes.settings.ok" = "OK"; + +/* Location: RepeaterSettingsView.swift - Default success message */ +"remoteNodes.settings.settingsApplied" = "Definições aplicadas"; + +/* Location: RepeaterSettingsView.swift - Device info section title */ +"remoteNodes.settings.deviceInfo" = "Informações do dispositivo"; + +/* Location: RepeaterSettingsView.swift - Firmware label */ +"remoteNodes.settings.firmware" = "Firmware"; + +/* Location: RepeaterSettingsView.swift - Device time label */ +"remoteNodes.settings.deviceTime" = "Hora do dispositivo"; + +/* Location: RepeaterSettingsView.swift - Radio parameters section title */ +"remoteNodes.settings.radioParameters" = "Parâmetros de rádio"; + +/* Location: RepeaterSettingsView.swift - Radio restart warning */ +"remoteNodes.settings.radioRestartWarning" = "Estas alterações só terão efeito depois de o repetidor ser reiniciado"; + +/* Location: RepeaterSettingsView.swift - Frequency label */ +"remoteNodes.settings.frequencyMHz" = "Frequência (MHz)"; + +/* Location: RepeaterSettingsView.swift - MHz placeholder */ +"remoteNodes.settings.mhz" = "MHz"; + +/* Location: RepeaterSettingsView.swift - Loading placeholder */ +"remoteNodes.settings.loading" = "A carregar..."; + +/* Location: RepeaterSettingsView.swift - Failed to load placeholder */ +"remoteNodes.settings.failedToLoad" = "Falha ao carregar"; + +/* Location: RepeaterSettingsView.swift - Bandwidth label */ +"remoteNodes.settings.bandwidthKHz" = "Largura de banda (kHz)"; + +/* Location: RepeaterSettingsView.swift - Accessibility label for bandwidth picker options - %@ is formatted bandwidth */ +"remoteNodes.settings.accessibility.bandwidthLabel" = "%@ kilohertz"; + +/* Location: RepeaterSettingsView.swift - Bandwidth accessibility hint */ +"remoteNodes.settings.bandwidthHint" = "Valores menores aumentam o alcance, mas diminuem a velocidade"; + +/* Location: RepeaterSettingsView.swift - Spreading factor label */ +"remoteNodes.settings.spreadingFactor" = "Fator de espalhamento"; + +/* Location: RepeaterSettingsView.swift - Accessibility label for spreading factor picker options - %d is the factor */ +"remoteNodes.settings.accessibility.spreadingFactorLabel" = "Fator de espalhamento %d"; + +/* Location: RepeaterSettingsView.swift - Spreading factor accessibility hint */ +"remoteNodes.settings.spreadingFactorHint" = "Valores maiores aumentam o alcance, mas diminuem a velocidade"; + +/* Location: RepeaterSettingsView.swift - Coding rate label */ +"remoteNodes.settings.codingRate" = "Taxa de codificação"; + +/* Location: RepeaterSettingsView.swift - Accessibility label for coding rate picker options - %d is the rate */ +"remoteNodes.settings.accessibility.codingRateLabel" = "Taxa de codificação %d"; + +/* Location: RepeaterSettingsView.swift - Coding rate accessibility hint */ +"remoteNodes.settings.codingRateHint" = "Valores maiores adicionam correção de erros, mas diminuem a velocidade"; + +/* Location: RepeaterSettingsView.swift - TX power label */ +"remoteNodes.settings.txPowerDbm" = "Potência TX (dBm)"; + +/* Location: RepeaterSettingsView.swift - dBm placeholder */ +"remoteNodes.settings.dbm" = "dBm"; + +/* Location: RepeaterSettingsView.swift - Apply radio settings button */ +"remoteNodes.settings.applyRadioSettings" = "Aplicar definições de rádio"; + +/* Location: RepeaterSettingsView.swift - Identity & location section title */ +"remoteNodes.settings.identityLocation" = "Identidade e localização"; + +/* Location: RepeaterSettingsView.swift - Latitude label */ +"remoteNodes.settings.latitude" = "Latitude"; + +/* Location: RepeaterSettingsView.swift - Lat placeholder */ +/* Location: RepeaterSettingsView.swift - Longitude label */ +"remoteNodes.settings.longitude" = "Longitude"; + +/* Location: RepeaterSettingsView.swift - Lon placeholder */ +/* Location: RepeaterSettingsView.swift - Pick on map button */ +"remoteNodes.settings.pickOnMap" = "Escolher no mapa"; + +/* Location: RepeaterSettingsView.swift - Apply identity settings button */ +"remoteNodes.settings.applyIdentitySettings" = "Aplicar definições de identidade"; + +/* Location: RepeaterSettingsView.swift - Contact info section title */ +"remoteNodes.settings.contactInfo" = "Informações de contacto"; + +/* Location: RepeaterSettingsView.swift - Contact info placeholder */ +"remoteNodes.settings.contactInfoPlaceholder" = "Frequência, nome do operador, sítio web..."; + +/* Location: RepeaterSettingsView.swift - Contact info footer */ +"remoteNodes.settings.contactInfoFooter" = "Dados de contacto públicos visíveis para outros nós. Utilize quebras de linha para separar os campos."; + +/* Location: RepeaterSettingsView.swift - Apply contact info button */ +"remoteNodes.settings.applyContactInfo" = "Aplicar informações de contacto"; + +/* Location: RepeaterSettingsView.swift - Contact info character count format */ +/* Location: RepeaterSettingsView.swift - Behavior section title */ +"remoteNodes.settings.behavior" = "Comportamento"; + +/* Location: RepeaterSettingsView.swift - Repeater mode toggle */ +"remoteNodes.settings.repeaterMode" = "Modo repetidor"; + +/* Location: RepeaterSettingsView.swift - Advert interval (0-hop) label */ +"remoteNodes.settings.advertInterval0Hop" = "Intervalo de Advert (0-hop)"; + +/* Location: RepeaterSettingsView.swift - Minutes unit */ +"remoteNodes.settings.min" = "min"; + +/* Location: RepeaterSettingsView.swift - Advert interval (flood) label */ +"remoteNodes.settings.advertIntervalFlood" = "Intervalo de Advert (difusão)"; + +/* Location: RepeaterSettingsView.swift - Hours unit */ +"remoteNodes.settings.hrs" = "h"; + +/* Location: RepeaterSettingsView.swift - Max flood hops label */ +"remoteNodes.settings.maxFloodHops" = "Máximo de saltos por difusão"; + +/* Location: RepeaterSettingsView.swift - Hops unit */ +"remoteNodes.settings.hops" = "saltos"; + +/* Location: RepeaterSettingsView.swift - Apply behavior settings button */ +"remoteNodes.settings.applyBehaviorSettings" = "Aplicar definições de comportamento"; + +/* Location: RepeaterSettingsView.swift - Security section title */ +"remoteNodes.settings.security" = "Segurança"; + +/* Location: RepeaterSettingsView.swift - New password placeholder */ +"remoteNodes.settings.newPassword" = "Nova palavra-passe"; + +/* Location: RepeaterSettingsView.swift - Confirm password placeholder */ +"remoteNodes.settings.confirmPassword" = "Confirmar palavra-passe"; + +/* Location: RepeaterSettingsView.swift - Change password button */ +"remoteNodes.settings.changePassword" = "Alterar palavra-passe"; + +/* Location: RepeaterSettingsView.swift - Security footer text */ +"remoteNodes.settings.securityFooter" = "Altere a palavra-passe de autenticação de administrador."; + +/* Location: RepeaterSettingsView.swift - Device info section footer */ +"remoteNodes.settings.deviceInfoFooter" = "Versão do firmware e relógio do dispositivo."; + +/* Location: RepeaterSettingsView.swift - Radio settings section footer */ +"remoteNodes.settings.radioFooter" = "Frequência, largura de banda, fator de espalhamento e taxa de codificação."; + +/* Location: RepeaterSettingsView.swift - Identity section footer */ +"remoteNodes.settings.identityFooter" = "Nome do repetidor e coordenadas GPS para apresentação no mapa."; + +/* Location: RepeaterSettingsView.swift - Behavior section footer */ +"remoteNodes.settings.behaviorFooter" = "Intervalos de Advert, saltos por difusão e modo repetidor."; + +/* Location: RepeaterSettingsView.swift - Device actions section header */ +"remoteNodes.settings.deviceActions" = "Ações do dispositivo"; + +/* Location: RepeaterSettingsView.swift - Send advert button */ +"remoteNodes.settings.sendAdvert" = "Enviar Advert"; + +/* Location: RepeaterSettingsView.swift - Sync time button */ +"remoteNodes.settings.syncTime" = "Sincronizar hora"; + +/* Location: RepeaterSettingsView.swift - Reboot device button */ +"remoteNodes.settings.rebootDevice" = "Reiniciar dispositivo"; + +/* Location: RepeaterSettingsView.swift - Reboot confirmation dialog title */ +"remoteNodes.settings.rebootConfirmTitle" = "Reiniciar repetidor?"; + +/* Location: RepeaterSettingsView.swift - Reboot confirmation dialog button */ +"remoteNodes.settings.reboot" = "Reiniciar"; + +/* Location: RepeaterSettingsView.swift - Reboot confirmation message */ +"remoteNodes.settings.rebootMessage" = "O repetidor irá reiniciar e ficará temporariamente indisponível."; + +// MARK: - Repeater Settings ViewModel Messages + +/* Location: RepeaterSettingsViewModel.swift - Radio not loaded error */ +"remoteNodes.settings.radioNotLoaded" = "Definições de rádio não carregadas"; + +/* Location: RepeaterSettingsViewModel.swift - Radio applied success */ +"remoteNodes.settings.radioAppliedSuccess" = "Definições de rádio aplicadas. Reinicie o dispositivo para que tenham efeito."; + +/* Location: RepeaterSettingsViewModel.swift - Radio apply failure */ +"remoteNodes.settings.radioApplyFailed" = "Não foi possível aplicar as definições de rádio"; + +/* Location: RepeaterSettingsViewModel.swift - General apply failure */ +"remoteNodes.settings.someSettingsFailedToApply" = "Não foi possível aplicar algumas definições"; + +/* Location: RepeaterSettingsViewModel.swift - Empty password error */ +"remoteNodes.settings.passwordEmpty" = "A palavra-passe não pode estar vazia"; + +/* Location: RepeaterSettingsViewModel.swift - Password mismatch error */ +"remoteNodes.settings.passwordMismatch" = "As palavras-passe não coincidem"; + +/* Location: RepeaterSettingsViewModel.swift - Password changed success */ +/* Location: RepeaterSettingsViewModel.swift - Password change failure */ +"remoteNodes.settings.passwordChangeFailed" = "Não foi possível alterar a palavra-passe"; + +/* Location: RepeaterSettingsViewModel.swift - Reboot sent success */ +"remoteNodes.settings.rebootSent" = "Comando de reinício enviado"; + +/* Location: RepeaterSettingsViewModel.swift - Advert sent success */ +"remoteNodes.settings.advertSent" = "Advert enviado"; + +/* Location: RepeaterSettingsViewModel.swift - Time synced success */ +"remoteNodes.settings.timeSynced" = "Hora sincronizada"; + +/* Location: RepeaterSettingsViewModel.swift - Clock ahead error */ +"remoteNodes.settings.clockAheadError" = "O relógio do repetidor está adiantado em relação à hora do telemóvel. Se estiver demasiado adiantado, reinicie o repetidor e sincronize a hora novamente."; + +/* Location: RepeaterSettingsViewModel.swift - Sync time failure */ +"remoteNodes.settings.syncTimeFailed" = "Não foi possível sincronizar a hora"; + +/* Location: RepeaterSettingsViewModel.swift - Unexpected response error */ +"remoteNodes.settings.unexpectedResponse" = "Resposta inesperada: %@"; + +/* Location: RepeaterSettingsViewModel.swift - Not connected error */ +/* Location: RepeaterSettingsViewModel.swift - Timeout error */ +/* Location: NodeSettingsViewModel.swift - No service error */ +"remoteNodes.settings.noService" = "Serviço indisponível"; + +/* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ +"remoteNodes.settings.advertIntervalValidation" = "Aceita 0 (desativado) ou 60-240 min"; + +/* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ +"remoteNodes.settings.floodIntervalValidation" = "Aceita 0 (desligado) ou 3-168 horas"; + +/* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ +"remoteNodes.settings.floodMaxValidation" = "Aceita 0-64 saltos"; + +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Aceita até %d bytes"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Aceita -90 a 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Aceita -180 a 180"; + +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regiões"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Guarde no repetidor para manter as alterações após os reinícios."; + +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Sem scope"; + +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Sem scope)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Região home"; + +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Scope predefinido"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Nenhum"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Aplica o scope aos pacotes originados por este nó, como os Adverts. Esta definição é guardada imediatamente no repetidor."; + +/* Location: RepeaterSettingsView.swift - No home region set */ +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "Quando desligado, os pacotes por difusão desta região são descartados"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Adicionar região"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Adicionar região"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Nome da região"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Guardar no repetidor"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Não foi possível guardar as regiões"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Região desconhecida"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remova primeiro as regiões filhas"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Não foi possível adicionar a região"; + +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Os nomes de região só podem conter letras, números e hífenes."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Os nomes de região estão limitados a %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Esta região já existe."; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Não foi possível remover a região"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "Nenhuma região configurada"; + +// MARK: - Repeater Status + +/* Location: RepeaterStatusView.swift - Navigation title */ +"remoteNodes.status.title" = "Estado do repetidor"; + +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Modo convidado"; +"remoteNodes.status.clockAhead" = "O relógio deste nó está %@ adiantado em relação ao seu rádio. Um nó com o relógio impreciso pode ignorar comandos ou mostrar horas erradas nas mensagens."; +"remoteNodes.status.clockBehind" = "O relógio deste nó está %@ atrasado em relação ao seu rádio. Um nó com o relógio impreciso pode ignorar comandos ou mostrar horas erradas nas mensagens."; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Informações de contacto"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Sem informações de contacto"; + +/* Location: RepeaterStatusView.swift - Owner info section footer */ +"remoteNodes.status.ownerInfoFooter" = "Informações de contacto e versão do firmware."; + +/* Location: RepeaterStatusView.swift - Status section header */ +"remoteNodes.status.statusSection" = "Estado"; + +/* Location: RepeaterStatusView.swift - Battery label */ +"remoteNodes.status.battery" = "Bateria"; + +/* Location: RepeaterStatusView.swift - Uptime label */ +"remoteNodes.status.uptime" = "Tempo de atividade"; + +/* Location: SharedNodeViews.swift - Airtime label */ +"remoteNodes.status.airtime" = "Tempo de antena"; + +/* Location: SharedNodeViews.swift - Airtime percent label */ +"remoteNodes.status.airtimePercent" = "Tempo de antena %%"; + +/* Location: RepeaterStatusView.swift - Last RSSI label */ +"remoteNodes.status.lastRssi" = "Último RSSI"; + +/* Location: RepeaterStatusView.swift - Last SNR label */ +"remoteNodes.status.lastSnr" = "Último SNR"; + +/* Location: RepeaterStatusView.swift - Noise floor label */ +"remoteNodes.status.noiseFloor" = "Ruído de fundo"; + +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Pacotes"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Enviados"; + +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Recebidos"; + +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Erros"; +/* Location: SharedNodeStatusViews.swift - Sent (direct) packets label */ +"remoteNodes.status.sentDirect" = "Enviados (direto)"; +/* Location: SharedNodeStatusViews.swift - Sent (flood) packets label */ +"remoteNodes.status.sentFlood" = "Enviados (Difusão)"; +/* Location: SharedNodeStatusViews.swift - Received (direct) packets label */ +"remoteNodes.status.receivedDirect" = "Recebidos (direto)"; +/* Location: SharedNodeStatusViews.swift - Received (flood) packets label */ +"remoteNodes.status.receivedFlood" = "Recebidos (Difusão)"; +/* Location: SharedNodeStatusViews.swift - Duplicate packets label */ +"remoteNodes.status.duplicates" = "Duplicados"; + +/* Location: RepeaterStatusView.swift - Neighbors section label */ +"remoteNodes.status.neighbors" = "Vizinhos"; + +/* Location: RepeaterStatusView.swift - No neighbors empty state */ +"remoteNodes.status.noNeighbors" = "Nenhum vizinho descoberto"; + +/* Location: RepeaterStatusView.swift - Battery curve section label */ +"remoteNodes.status.batteryCurve" = "Curva da bateria"; + +/* Location: RepeaterStatusView.swift - Telemetry section label */ +"remoteNodes.status.telemetry" = "Telemetria"; + +/* Location: RepeaterStatusView.swift - No sensor data empty state */ +"remoteNodes.status.noSensorData" = "Sem dados de sensores"; + +/* Location: RepeaterStatusView.swift - No telemetry data empty state */ +"remoteNodes.status.noTelemetryData" = "Sem dados de telemetria"; + +/* Location: RepeaterStatusView.swift - Unknown neighbor name */ +"remoteNodes.status.unknown" = "Desconhecido"; + +/* Location: RepeaterStatusView.swift - Seconds ago format */ +"remoteNodes.status.secondsAgo" = "há %ds"; + +/* Location: RepeaterStatusView.swift - Minutes ago format */ +"remoteNodes.status.minutesAgo" = "há %dm"; + +/* Location: RepeaterStatusView.swift - Hours ago format */ +"remoteNodes.status.hoursAgo" = "há %dh"; + +/* Location: RepeaterStatusView.swift - SNR display format */ +"remoteNodes.status.snrFormat" = "SNR %@dB"; + +/* Location: RepeaterStatusView.swift - Accessibility label for possible match indicator */ +"remoteNodes.status.possibleMatch" = "Possível correspondência, associada pelo prefixo curto"; + +/* Location: RepeaterStatusView.swift - Title for possible match explanation popover */ +"remoteNodes.status.possibleMatchTitle" = "Possível correspondência"; + +/* Location: RepeaterStatusView.swift - Explanation of what a possible match means */ +"remoteNodes.status.possibleMatchExplanation" = "Vários nós partilham este prefixo. O nome apresentado pode não estar correto."; + +// MARK: - Repeater Status ViewModel Messages + +/* Location: RepeaterStatusViewModel.swift - Request timed out */ +"remoteNodes.status.requestTimedOut" = "O pedido expirou"; +"remoteNodes.status.telemetryTimedOut" = "Tempo esgotado ou o contacto tem a telemetria desativada"; + +/* Location: RepeaterStatusViewModel.swift - Uptime 1 day format */ +"remoteNodes.status.uptime1Day" = "1 dia %dh %dm"; + +/* Location: RepeaterStatusViewModel.swift - Uptime multiple days format */ +"remoteNodes.status.uptimeDays" = "%d dias %dh %dm"; + +/* Location: RepeaterStatusViewModel.swift - Uptime hours format */ +"remoteNodes.status.uptimeHours" = "%dh %dm"; + +/* Location: RepeaterStatusViewModel.swift - Uptime minutes format */ +"remoteNodes.status.uptimeMinutes" = "%dm"; + +/* Location: RepeaterStatusViewModel.swift - Failed to load OCV settings */ +"remoteNodes.status.ocvLoadFailed" = "Não foi possível carregar as definições da curva da bateria"; + +/* Location: RepeaterStatusViewModel.swift - Cannot save OCV error */ +"remoteNodes.status.ocvSaveNoContact" = "Não é possível guardar: contacto não encontrado"; + +/* Location: RepeaterStatusViewModel.swift - OCV save failed */ +"remoteNodes.status.ocvSaveFailed" = "Não foi possível guardar: %@"; + +/* Location: RepeaterStatusView.swift - Channel header for grouped telemetry */ +"remoteNodes.status.channel" = "Canal %d"; + +/* Location: RepeaterStatusView.swift - Telemetry section footer */ +"remoteNodes.status.telemetryFooter" = "Leituras de sensores como temperatura, humidade e tensão."; + +/* Location: SharedNodeStatusViews.swift - Telemetry sensor type display labels */ +"remoteNodes.status.sensor.digitalInput" = "Entrada digital"; +"remoteNodes.status.sensor.digitalOutput" = "Saída digital"; +"remoteNodes.status.sensor.analogInput" = "Entrada analógica"; +"remoteNodes.status.sensor.analogOutput" = "Saída analógica"; +"remoteNodes.status.sensor.genericSensor" = "Sensor"; +"remoteNodes.status.sensor.illuminance" = "Iluminância"; +"remoteNodes.status.sensor.presence" = "Presença"; +"remoteNodes.status.sensor.temperature" = "Temperatura"; +"remoteNodes.status.sensor.humidity" = "Humidade"; +"remoteNodes.status.sensor.accelerometer" = "Acelerómetro"; +"remoteNodes.status.sensor.barometer" = "Pressão"; +"remoteNodes.status.sensor.voltage" = "Tensão"; +"remoteNodes.status.sensor.current" = "Corrente"; +"remoteNodes.status.sensor.frequency" = "Frequência"; +"remoteNodes.status.sensor.percentage" = "Percentagem"; +"remoteNodes.status.sensor.altitude" = "Altitude"; +"remoteNodes.status.sensor.load" = "Carga"; +"remoteNodes.status.sensor.concentration" = "Concentração"; +"remoteNodes.status.sensor.power" = "Potência"; +"remoteNodes.status.sensor.distance" = "Distância"; +"remoteNodes.status.sensor.energy" = "Energia"; +"remoteNodes.status.sensor.direction" = "Direção"; +"remoteNodes.status.sensor.unixTime" = "Hora"; +"remoteNodes.status.sensor.gyrometer" = "Girómetro"; +"remoteNodes.status.sensor.colour" = "Cor"; +"remoteNodes.status.sensor.gps" = "GPS"; +"remoteNodes.status.sensor.switchValue" = "Interruptor"; + +/* Location: RepeaterStatusView.swift - Neighbors section footer */ +"remoteNodes.status.neighborsFooter" = "Outros nós descobertos por este repetidor e a qualidade do respetivo sinal."; + +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Descobrir vizinhos"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "A descobrir... %ds"; + +/* Location: RepeaterStatusView.swift - Battery curve section footer */ +"remoteNodes.status.batteryCurveFooter" = "Mapeamento de tensão para percentagem usado para estimar o nível da bateria."; + +/* Location: NodeTelemetryView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Atualizar"; + +/* Location: SharedNodeStatusViews.swift - Per-section reload button accessibility label for the status counters */ +"remoteNodes.status.accessibility.reloadStatus" = "Recarregar estado"; + +/* Location: SharedNodeStatusViews.swift - Per-section reload button accessibility label for telemetry */ +"remoteNodes.status.accessibility.reloadTelemetry" = "Recarregar telemetria"; + +/* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for owner info */ +"remoteNodes.status.accessibility.reloadOwnerInfo" = "Recarregar informações de contacto"; + +/* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ +"remoteNodes.status.accessibility.reloadNeighbors" = "Recarregar vizinhos"; + +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Ver no mapa"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Ver vizinhos no mapa"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Mapa de vizinhos"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d vizinhos não mostrados"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + +// MARK: - Location Map + +/* Location: SharedNodeStatusViews.swift - Accessibility label for the location "View on Map" affordance */ +"remoteNodes.status.accessibility.viewLocationOnMap" = "Ver localização no mapa"; + +/* Location: NodeStatusRoute.swift - Navigation title for the live location map */ +"remoteNodes.status.locationMapTitle" = "Localização"; + +// MARK: - History + +/* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ +"remoteNodes.history.vsMinutesAgo" = "Desde a última visita (há %dm)"; + +/* Location: RepeaterStatusViewModel.swift - Delta timestamp (hours ago) */ +"remoteNodes.history.vsHoursAgo" = "Desde a última visita (há %dh)"; + +/* Location: RepeaterStatusViewModel.swift - Delta timestamp (date) */ +"remoteNodes.history.vsDate" = "Desde %@"; + +/* Location: NodeStatusHistoryView.swift - History navigation title */ +"remoteNodes.history.title" = "Histórico"; + +/* Location: NeighborRow - New neighbor badge */ +"remoteNodes.history.new" = "Novo"; + +/* Location: NeighborRow - Not seen status */ +"remoteNodes.history.notSeen" = "Não visto"; + +/* Location: NodeStatusHistoryView.swift - Empty state message */ +"remoteNodes.history.checkBack" = "É registado um instantâneo no máximo a cada 15 minutos. Volte depois da próxima visita para ver as tendências."; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.week" = "1 sem"; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.month" = "1M"; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.threeMonths" = "3M"; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.all" = "Tudo"; + +/* Location: NodeStatusHistoryView.swift - Battery chart title */ +"remoteNodes.history.battery" = "Bateria"; + +/* Location: NodeStatusHistoryView.swift - SNR chart title */ +"remoteNodes.history.snr" = "SNR"; + +/* Location: NodeStatusHistoryView.swift - RSSI chart title */ +"remoteNodes.history.rssi" = "RSSI"; + +/* Location: NodeStatusHistoryView.swift - Noise floor chart title */ +"remoteNodes.history.noiseFloor" = "Ruído de fundo"; + +/* Location: NodeStatusHistoryView.swift - Neighbor count chart title */ +/* Location: RadioMetricCharts.swift - Sent packets chart title, under the Packets group header */ +"remoteNodes.history.packetsSent" = "Enviados"; + +/* Location: RadioMetricCharts.swift - Received packets chart title, under the Packets group header */ +"remoteNodes.history.packetsReceived" = "Recebidos"; + +/* Location: RadioMetricCharts.swift - Receive errors chart title, under the Packets group header */ +"remoteNodes.history.receiveErrors" = "Erros"; +/* Location: RadioMetricCharts.swift - Direct packet series legend */ +"remoteNodes.history.direct" = "Direto"; +/* Location: RadioMetricCharts.swift - Flood packet series legend */ +"remoteNodes.history.flood" = "Difusão"; +/* Location: RadioMetricCharts.swift - Duplicates chart title, under the Packets group header */ +"remoteNodes.history.duplicates" = "Duplicados"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Pacotes"; + +/* Location: NeighborHistoryView.swift - Active status */ +/* Location: NeighborHistoryView.swift - Last seen status */ +/* Location: NeighborHistoryView.swift - Neighbors section title */ +/* Location: StatusDeltaView.swift - Accessibility: metric increased */ +"remoteNodes.history.a11y.increased" = "aumentou"; + +/* Location: StatusDeltaView.swift - Accessibility: metric decreased */ +"remoteNodes.history.a11y.decreased" = "diminuiu"; + +/* Location: StatusDeltaView.swift - Accessibility: metric improved */ +"remoteNodes.history.a11y.improved" = "melhorou"; + +/* Location: StatusDeltaView.swift - Accessibility: metric degraded */ +"remoteNodes.history.a11y.degraded" = "piorou"; + +/* Location: StatusDeltaView.swift - Accessibility: delta description format (quality, direction, value, unit) */ +"remoteNodes.history.a11y.deltaDescription" = "%1$@, %2$@ em %3$@%4$@"; + +/* Location: HistoryTimeRangePicker.swift - Accessibility label for time range picker */ +"remoteNodes.history.timeRange" = "Intervalo de tempo"; + +/* Location: NodeStatusHistoryView.swift - Footer about data retention */ +"remoteNodes.history.retentionNotice" = "Os dados do histórico com mais de um ano são removidos automaticamente."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Rádio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensores"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Vizinhos"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Location section header */ +"remoteNodes.history.locationSection" = "Localização"; + +/* Location: LocationHistorySection.swift - Purpose: Header for the location report list beneath the map preview */ +"remoteNodes.history.locationReportsHeader" = "Histórico de localização"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Ligue a este nó pelo menos uma vez para ver o histórico."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "Estes dados são capturados ao visualizar a secção %@ durante uma sessão de telemetria em direto."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Histórico de telemetria"; + +// MARK: - Room Conversation + +/* Location: RoomConversationView.swift - Disconnected status */ +"remoteNodes.room.disconnected" = "Desligado"; + +/* Location: RoomConversationView.swift - Empty state title */ +"remoteNodes.room.noMessagesYet" = "Ainda não há mensagens públicas"; + +/* Location: RoomConversationView.swift - Empty state hint */ +"remoteNodes.room.beFirstToPost" = "Seja o primeiro a publicar"; + +/* Location: RoomConversationView.swift - Input placeholder */ +"remoteNodes.room.publicMessage" = "Mensagem pública"; + +/* Location: RoomConversationView.swift - Read-only banner */ +"remoteNodes.room.viewOnlyBanner" = "Só de leitura. Entre como membro para publicar"; + +/* Location: RoomConversationView.swift - Hint text for read-only banner */ +"remoteNodes.room.viewOnlyHint" = "Toque para iniciar sessão"; + +/* Location: RoomConversationView.swift - Room info sheet title */ +"remoteNodes.room.infoTitle" = "Informações da sala"; + +/* Location: RoomConversationView.swift - Details section header */ +"remoteNodes.room.details" = "Detalhes"; + +/* Location: RoomConversationView.swift - Permission label */ +"remoteNodes.room.permission" = "Permissão"; + +/* Location: RoomInfoSheet.swift, RoomConversationView.swift - Guest permission level */ +"remoteNodes.permission.guest" = "Convidado"; + +/* Location: RoomInfoSheet.swift, RoomConversationView.swift - Member (read-write) permission level */ +"remoteNodes.permission.member" = "Membro"; + +/* Location: RoomInfoSheet.swift, RoomConversationView.swift - Admin permission level */ +"remoteNodes.permission.admin" = "Administrador"; + +/* Location: RoomConversationView.swift - Status label */ +"remoteNodes.room.status" = "Estado"; + +/* Location: RoomConversationView.swift - Connected status */ +"remoteNodes.room.connected" = "Ligado"; + +/* Location: RoomConversationView.swift - Activity section header */ +"remoteNodes.room.activity" = "Atividade"; + +/* Location: RoomConversationView.swift - Last connected label */ +"remoteNodes.room.lastConnected" = "Última ligação"; + +/* Location: RoomConversationView.swift - Identification section header */ +"remoteNodes.room.identification" = "Identificação"; + +/* Location: RoomConversationView.swift - Public key label */ +"remoteNodes.room.publicKey" = "Chave pública"; + +/* Location: RoomConversationView.swift - Disconnected banner text */ +"remoteNodes.room.disconnectedBanner" = "Desligado"; + +/* Location: RoomConversationView.swift - Accessibility hint for disconnected banner */ +"remoteNodes.room.disconnectedHint" = "Toque para voltar a ligar"; + +/* Location: RoomConversationView.swift - VoiceOver announcement when room reconnects */ +"remoteNodes.room.reconnected" = "Sala novamente ligada"; + +// MARK: - Room Message Bubble + +/* Location: RoomMessageBubble.swift - Accessibility hint for retry button */ +"remoteNodes.room.message.retryHint" = "Toque duas vezes para reenviar esta mensagem"; + +/* Location: RoomMessageBubble.swift - Accessibility label for failed message status */ +"remoteNodes.room.message.status.failedLabel" = "Não foi possível enviar a mensagem. Toque duas vezes no botão de tentar novamente para reenviar."; + +/* Location: RoomMessageBubble.swift - Accessibility label for sending message status */ +"remoteNodes.room.message.status.sendingLabel" = "A enviar mensagem"; + +/* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ +"remoteNodes.room.message.status.deliveredLabel" = "Mensagem entregue"; + +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Estado da sala"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Publicações recebidas"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Publicações enviadas"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Definições da sala"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Definições da sala"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Palavra-passe de convidado e acesso só de leitura."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Palavra-passe de convidado"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Permitir só de leitura"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Permite que utilizadores sem palavra-passe se liguem no modo só de leitura."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Aplicar definições da sala"; + +/* Location: RoomSettingsView.swift - Room behavior section footer */ +"remoteNodes.roomSettings.behaviorFooter" = "Intervalos de Advert e saltos por difusão."; + +/* Location: RoomSettingsView.swift - Identity section footer */ +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reiniciar sala?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "A sala irá reiniciar e ficará temporariamente indisponível."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Estas alterações só terão efeito depois de a sala ser reiniciada"; + +/* Location: RoomSettingsView.swift - No service error */ +/* Location: RoomSettingsView.swift - Not connected error */ +/* Location: RoomSettingsView.swift - Clock ahead error */ +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +/* Location: RoomInfoSheet.swift - Management button */ +// MARK: - Node CLI + +/* Location: NodeCliView.swift - Banner shown when connected to a node - %@ is node name */ +"remoteNodes.nodeCli.bannerConnected" = "Ligado a %@"; + +/* Location: NodeCliView.swift - Hint shown below connected banner */ +"remoteNodes.nodeCli.bannerHint" = "Introduza 'help' para ver os comandos disponíveis."; + +/* Location: NodeCliViewModel.swift - Toast after reboot command succeeds */ +"remoteNodes.nodeCli.rebootSent" = "Comando de reinício enviado"; + +/* Location: NodeCliViewModel.swift - Toast after command is cancelled */ +"remoteNodes.nodeCli.cancelled" = "Comando cancelado"; + +/* Location: NodeCliViewModel.swift - Header line in help output */ +"remoteNodes.nodeCli.helpHeader" = "Comandos disponíveis:"; + +/* Location: NodeCliViewModel.swift - Help entry for 'help' command */ +"remoteNodes.nodeCli.helpHelp" = " help\n Mostrar esta ajuda"; + +/* Location: NodeCliViewModel.swift - Help entry for 'clear' command */ +"remoteNodes.nodeCli.helpClear" = " clear\n Limpar o terminal"; + +/* Location: NodeCliViewModel.swift - Help entry for 'clear stats' command */ +"remoteNodes.nodeCli.helpClearStats" = " clear stats\n Repor as estatísticas do nó"; + +/* Location: NodeCliViewModel.swift - Help entry for 'reboot' command */ +"remoteNodes.nodeCli.helpReboot" = " reboot\n Reiniciar este nó"; + +/* Location: NodeCliViewModel.swift - Passthrough note at end of help output */ +"remoteNodes.nodeCli.helpPassthrough" = "Qualquer outra entrada é enviada ao nó."; + +// MARK: - Shared + +/* Location: Multiple files - Cancel button */ +"remoteNodes.cancel" = "Cancelar"; + +/* Location: Multiple files - Done button */ +"remoteNodes.done" = "Concluído"; + +/* Location: Multiple files - Name label */ +"remoteNodes.name" = "Nome"; diff --git a/MC1/Resources/Localization/pt.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/pt.lproj/RemoteNodes.stringsdict new file mode 100644 index 000000000..22cf942f4 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/RemoteNodes.stringsdict @@ -0,0 +1,29 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d vizinho não mostrado + other + %d vizinhos não mostrados + + + + diff --git a/MC1/Resources/Localization/pt.lproj/Settings.strings b/MC1/Resources/Localization/pt.lproj/Settings.strings new file mode 100644 index 000000000..64ffceae2 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Settings.strings @@ -0,0 +1,1729 @@ +/* + Settings.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the Settings feature. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Main Settings View + +/* Navigation title for the main settings screen */ +"title" = "Definições"; + +/* Placeholder shown in split view detail when no setting is selected */ +"selectSetting" = "Selecione uma definição"; + +/* Section header for device-specific settings */ +"myDevice.header" = "O meu dispositivo"; + +/* Section header for app-level settings */ +"appSettings.header" = "Definições da app"; + +/* Footer text for the advanced settings row */ +/* Label for the advanced settings navigation row */ +"advancedSettings.title" = "Definições avançadas"; + +/* Version display prefix with version number */ +"version" = "v%@"; + +/* Build number display with build number */ +"build" = "Compilação %@"; + +// MARK: - Demo Mode Section + +/* Section header for demo mode */ +"demoMode.header" = "Modo demo"; + +/* Toggle label to enable demo mode */ +"demoMode.enabled" = "Ativado"; + +/* Footer explaining what demo mode does */ +"demoMode.footer" = "O modo demo permite testar sem hardware, com dados simulados."; + +// MARK: - Device Section + +/* Section header for device information */ +"device.header" = "Dispositivo"; + +/* Status shown when device is connected */ +"device.connected" = "Ligado"; + +/* Footer shown when no device is connected */ +"device.noDeviceConnected" = "Nenhum dispositivo MeshCore ligado"; + +/* Button to connect a device */ +"device.connect" = "Ligar dispositivo"; + +// MARK: - Device Info View + +/* Navigation title for device info screen */ +"deviceInfo.title" = "Sobre o dispositivo"; + +/* Section header for connection status */ +"deviceInfo.connection.header" = "Ligação"; + +/* Label for connection status */ +"deviceInfo.connection.status" = "Estado"; + +/* Section header for power and storage */ +"deviceInfo.powerStorage.header" = "Energia e armazenamento"; + +/* Label for battery level */ +"deviceInfo.battery" = "Bateria"; + +/* Label for storage used */ +"deviceInfo.storageUsed" = "Armazenamento usado"; + +/* Combined label for battery and storage when loading */ +"deviceInfo.batteryAndStorage" = "Bateria e armazenamento"; + +/* Section header for firmware information */ +"deviceInfo.firmware.header" = "Firmware"; + +/* Label for firmware version */ +"deviceInfo.firmwareVersion" = "Versão do firmware"; + +/* Label for build date */ +"deviceInfo.buildDate" = "Data de compilação"; + +/* Label for manufacturer */ +"deviceInfo.manufacturer" = "Fabricante"; + +/* Placeholder when a value is unknown */ +"deviceInfo.unknown" = "Desconhecido"; + +/* Section header for device capabilities */ +"deviceInfo.capabilities.header" = "Capacidades"; + +/* Label for max nodes capability */ +"deviceInfo.maxNodes" = "Capacidade máxima de nós"; + +/* Label for max channels capability */ +"deviceInfo.maxChannels" = "Capacidade máxima de canais"; + +/* Label for max TX power capability */ +"deviceInfo.maxTxPower" = "Potência de TX máxima"; + +/* TX power display format with dBm unit */ +"deviceInfo.txPowerFormat" = "%@ dBm"; + +/* Firmware version fallback format when no version string is reported - %@ is the numeric version code */ +"deviceInfo.firmwareVersionFormat" = "v%@"; + +/* Section header for identity information */ +/* Label for public key */ +"deviceInfo.publicKey" = "Chave pública"; + +/* Button to share contact information */ +"deviceInfo.shareContact" = "Partilhar o meu contacto"; + +/* Title for ContentUnavailableView when no device is connected */ +"deviceInfo.noDevice.title" = "Nenhum dispositivo ligado"; + +/* Description for ContentUnavailableView when no device is connected */ +"deviceInfo.noDevice.description" = "Ligue um dispositivo MeshCore para ver as respetivas informações"; + +/* Fallback manufacturer name */ +"deviceInfo.defaultManufacturer" = "Dispositivo MeshCore"; + +// MARK: - Public Key View + +/* Navigation title for public key screen */ +"publicKey.title" = "Chave pública"; + +/* Section header describing the key type */ +"publicKey.header" = "Chave pública Ed25519 de 32 bytes"; + +/* Footer explaining the public key's purpose */ +"publicKey.footer" = "Esta chave identifica de forma exclusiva o dispositivo na rede mesh"; + +// MARK: - Private Key View + +/* Section header describing the key type */ +"privateKey.header" = "Chave privada Ed25519 de 64 bytes"; + +/* Section header for base64 representation */ +"publicKey.base64.header" = "Base64"; + +/* Button to copy key to clipboard */ +"publicKey.copy" = "Copiar para a área de transferência"; + +// MARK: - Device Selection Sheet + +/* Navigation title for device selection */ +"deviceSelection.title" = "Ligar dispositivo"; + +/* Button to connect to selected device */ +/* Section header for previously paired devices */ +"deviceSelection.previouslyPaired" = "Emparelhados anteriormente"; + +/* Footer text prompting user to select a device */ +/* Button to connect via WiFi */ +"deviceSelection.connectViaWifi" = "Ligar via Wi-Fi"; + +/* Button to scan for Bluetooth devices */ +"deviceSelection.scanBluetooth" = "Procurar dispositivo Bluetooth"; + +/* Title for empty state when no devices are paired */ +"deviceSelection.noPairedDevices" = "Nenhum dispositivo emparelhado"; + +/* Description for empty state */ +"deviceSelection.noPairedDescription" = "Ainda não emparelhou nenhum dispositivo."; + +/* Button to scan for new devices */ +"deviceSelection.scanForDevices" = "Procurar dispositivos"; + +/* Accessibility label for a device connected to another app - %1$@ is device name */ +"deviceSelection.accessibility.connectedElsewhereLabel" = "%1$@, ligado a outra app"; + +/* Accessibility label for a device - %1$@ is device name, %2$@ is connection type */ +"deviceSelection.accessibility.deviceLabel" = "%1$@, %2$@"; + +/* Accessibility hint for a device connected to another app */ +"deviceSelection.accessibility.connectedElsewhereHint" = "Este dispositivo está a ser usado por outra app. Ligar pode causar problemas de comunicação."; + +/* Accessibility hint for selecting a device */ +"deviceSelection.accessibility.selectHint" = "Toque duas vezes para ligar"; + +/* Accessibility hint for a device that is out of Bluetooth range */ +"deviceSelection.accessibility.outOfRangeHint" = "O dispositivo está fora do alcance do Bluetooth"; + +/* Label shown when device is connected to another app */ +"deviceSelection.connectedElsewhere" = "Ligado noutro sítio"; + +/* Fallback connection type description */ +"deviceSelection.bluetooth" = "Bluetooth"; + +// MARK: - Location Picker View + +/* Navigation title for location picker */ +"locationPicker.title" = "Definir localização"; + +/* Marker title for node location on map */ +/* Button to clear the selected location */ +"locationPicker.clearLocation" = "Limpar localização"; + +/* Button to drop a pin at the map center */ +"locationPicker.dropPin" = "Colocar ponto no centro"; + +/* Label for latitude display */ +"locationPicker.latitude" = "Latitude:"; + +/* Label for longitude display */ +"locationPicker.longitude" = "Longitude:"; + +// MARK: - Trusted Contacts Picker + +/* Navigation title for trusted contacts picker */ +"trustedContacts.title" = "Contactos de confiança"; + +/* Title for empty state when no contacts exist */ +"trustedContacts.noContacts" = "Nenhum contacto"; + +/* Description for empty state */ +"trustedContacts.noContactsDescription" = "Adicione contactos para selecionar os de confiança"; + +/* Title for empty state when search returns no results */ +"trustedContacts.noResults" = "Nenhum resultado"; + +/* Description for empty state when search returns no results */ +"trustedContacts.noResultsDescription" = "Nenhum contacto corresponde a \"%@\""; + +/* Search prompt for trusted contacts list */ +"trustedContacts.searchPrompt" = "Pesquisar contactos"; + +/* Toggle label for showing only favorite contacts */ +"trustedContacts.favoritesOnly" = "Apenas favoritos"; + +/* Apply button label */ +"trustedContacts.apply" = "Aplicar"; + +// MARK: - About Section + +/* Section header for about links */ +"about.header" = "Acerca"; + +/* Link to MeshCore website */ +"about.website" = "Website do MeshCore"; + +/* Link to MeshCore online map */ +"about.onlineMap" = "Mapa online do MeshCore"; + +/* Link to GitHub repository */ +"about.github" = "GitHub"; + +/* Link to the privacy policy */ +"about.privacyPolicy" = "Política de privacidade"; + +// MARK: - Advanced Radio Section + +/* Section header for radio configuration */ +"advancedRadio.header" = "Configuração do rádio"; + +/* Label for frequency input */ +"advancedRadio.frequency" = "Frequência (MHz)"; + +/* Placeholder for frequency text field */ +"advancedRadio.frequencyPlaceholder" = "MHz"; + +/* Label for bandwidth picker */ +"advancedRadio.bandwidth" = "Largura de banda (kHz)"; + +/* Label for spreading factor picker */ +"advancedRadio.spreadingFactor" = "Fator de espalhamento (SF)"; + +/* Label for coding rate picker */ +"advancedRadio.codingRate" = "Taxa de codificação (CR)"; + +/* Label for TX power input */ +"advancedRadio.txPower" = "Potência de TX (dBm)"; + +/* Placeholder for TX power text field */ +"advancedRadio.txPowerPlaceholder" = "dBm"; + +/* Accessibility label for bandwidth picker options - %@ is formatted bandwidth value */ +"advancedRadio.accessibility.bandwidthLabel" = "%@ kilohertz"; + +/* Accessibility label for spreading factor picker options - %d is the factor value */ +"advancedRadio.accessibility.spreadingFactorLabel" = "Fator de espalhamento %d"; + +/* Accessibility label for coding rate picker options - %d is the rate value */ +"advancedRadio.accessibility.codingRateLabel" = "Taxa de codificação %d"; + +/* Accessibility hint for bandwidth picker */ +"advancedRadio.accessibility.bandwidthHint" = "Valores menores aumentam o alcance, mas diminuem a velocidade"; + +/* Accessibility hint for spreading factor picker */ +"advancedRadio.accessibility.spreadingFactorHint" = "Valores maiores aumentam o alcance, mas diminuem a velocidade"; + +/* Accessibility hint for coding rate picker */ +"advancedRadio.accessibility.codingRateHint" = "Valores maiores adicionam correção de erros, mas diminuem a velocidade"; + +/* Button to apply radio settings */ +"advancedRadio.apply" = "Aplicar definições do rádio"; + +/* Footer warning about incorrect radio settings */ +"advancedRadio.footer" = "Aviso: definições incorretas podem impedir a comunicação com outros dispositivos mesh."; + +/* Error message for invalid input */ +"advancedRadio.invalidInput" = "Valores de entrada inválidos ou dispositivo não ligado"; + +/* Toggle label for repeat mode in advanced radio */ +"advancedRadio.repeatMode" = "Modo de repetidor"; + +/* Footer explaining repeat mode in advanced radio */ +"advancedRadio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para zonas remotas. Frequências válidas: 433, 869.495, 918 MHz."; + +// MARK: - Path Hash Mode Section + +/* Section header for path hash mode */ +"pathHashMode.header" = "Tamanho do hash da rota"; + +/* Label for path hash mode picker */ +"pathHashMode.label" = "Tamanho do hash"; + +/* Path hash mode option: 1 byte */ +"pathHashMode.oneByte" = "1 byte"; + +/* Path hash mode option: 2 bytes */ +"pathHashMode.twoBytes" = "2 bytes"; + +/* Path hash mode option: 3 bytes */ +"pathHashMode.threeBytes" = "3 bytes"; + +/* Footer explaining path hash mode tradeoff */ +"pathHashMode.footer" = "Hashes maiores reduzem colisões de encaminhamento, mas limitam o número máximo de saltos por rota. Os repetidores com firmware anterior a 1.14.0 não retransmitem mensagens com hash superior a 1 byte."; + +// MARK: - Default Flood Scope Section + +/* Section header for default flood scope picker */ +"defaultFloodScope.header" = "Scope de difusão predefinido"; + +/* Footer explaining default flood scope */ +"defaultFloodScope.footer" = "Quando definido, o dispositivo aplica este scope aos envios por difusão, a menos que um scope específico do canal o substitua. O scope é guardado no dispositivo e mantém-se após reinícios."; + +/* Option to clear the persisted default flood scope */ +"defaultFloodScope.disabled" = "Nenhum"; + +// MARK: - Battery Curve Section + +/* Accessibility label for voltage text field - %d is the percentage level */ +"batteryCurve.accessibility.voltageLabel" = "Tensão a %d por cento"; + +/* Accessibility value for voltage text field - %d is the millivolt value */ +"batteryCurve.accessibility.voltageValue" = "%d milivolts"; + +/* Accessibility hint for voltage text field */ +"batteryCurve.accessibility.voltageHint" = "Introduza a tensão esperada neste nível de carga"; + +/* Section header for battery curve */ +"batteryCurve.header" = "Curva da bateria"; + +/* Footer explaining battery curve configuration */ +"batteryCurve.footer" = "Configure a curva de tensão para percentagem da bateria do dispositivo."; + +/* Label for preset picker */ +"batteryCurve.preset" = "Predefinição"; + +/* Option for custom battery curve */ +"batteryCurve.custom" = "Personalizado"; + +/* Disclosure group label for editing values */ +"batteryCurve.editValues" = "Editar valores"; + +/* Validation error for value out of range - %d is the percentage level */ +"batteryCurve.validation.outOfRange" = "O valor em %d%% tem de ser 1000-99999 mV"; + +/* Validation error for non-descending values */ +"batteryCurve.validation.notDescending" = "Os valores têm de estar por ordem decrescente"; + +/* Unit label for millivolts */ +"batteryCurve.mV" = "mV"; + +// MARK: - Bluetooth Section + +/* Section header for Bluetooth settings */ +"bluetooth.header" = "Bluetooth"; + +/* Label for PIN type picker */ +"bluetooth.pinType" = "Tipo de PIN"; + +/* PIN type option for default PIN */ +"bluetooth.pinType.default" = "Predefinido"; + +/* PIN type option for custom PIN */ +"bluetooth.pinType.custom" = "PIN personalizado"; + +/* Label showing current PIN */ +"bluetooth.currentPin" = "PIN atual"; + +/* Button to change the PIN */ +"bluetooth.changePin" = "Alterar PIN"; + +/* Button to change the device display name */ +"bluetooth.changeDisplayName" = "Alterar nome apresentado"; + +/* Footer explaining default PIN */ +"bluetooth.defaultPinFooter" = "O PIN predefinido é 123456. Os dispositivos com ecrã mostram o próprio PIN."; + +/* Alert title for setting custom PIN */ +"bluetooth.alert.setPin.title" = "Definir PIN personalizado"; + +/* Alert title for changing custom PIN */ +"bluetooth.alert.changePin.title" = "Alterar PIN personalizado"; + +/* Placeholder for PIN text field */ +"bluetooth.pinPlaceholder" = "PIN de 6 dígitos"; + +/* Button to set the PIN */ +"bluetooth.setPin" = "Definir PIN"; + +/* Alert message for setting PIN */ +"bluetooth.alert.setPin.message" = "Introduza um PIN de 6 dígitos. O dispositivo irá reiniciar para aplicar a alteração."; + +/* Alert message for changing PIN */ +"bluetooth.alert.changePin.message" = "Introduza um novo PIN de 6 dígitos. O dispositivo irá reiniciar para aplicar a alteração."; + +/* Alert title for confirming PIN type change */ +"bluetooth.alert.changePinType.title" = "Alterar o tipo de PIN?"; + +/* Button to confirm change */ +"bluetooth.alert.change" = "Alterar"; + +/* Alert message for PIN type change */ +"bluetooth.alert.changePinType.message" = "O dispositivo irá reiniciar para aplicar a alteração."; + +/* Error for invalid PIN format */ +"bluetooth.error.invalidPin" = "O PIN tem de ser um número de 6 dígitos entre 100000 e 999999"; + +// MARK: - Contacts Settings Section + +/* Section header for nodes/contacts settings */ +/* Toggle label for auto-add nodes */ +/* Description for auto-add nodes toggle */ +// MARK: - Nodes Settings Section + +/* Section header for nodes settings */ +"nodes.header" = "Nós"; + +/* Label for auto-add mode picker */ +"nodes.autoAddMode" = "Modo de adição automática"; + +/* Auto-add mode: manual */ +"nodes.autoAddMode.manual" = "Manual"; + +/* Auto-add mode: manual description */ +"nodes.autoAddMode.manualDescription" = "Reveja todos os nós em Descobrir antes de adicionar"; + +/* Auto-add mode: selected types */ +"nodes.autoAddMode.selectedTypes" = "Tipos selecionados"; + +/* Auto-add mode: selected types description */ +"nodes.autoAddMode.selectedTypesDescription" = "Adicionar automaticamente apenas os tipos ativados abaixo"; + +/* Auto-add mode: all */ +"nodes.autoAddMode.all" = "Todos"; + +/* Auto-add mode: all description */ +"nodes.autoAddMode.allDescription" = "Adicionar automaticamente todos os nós descobertos"; + +/* Section header for auto-add types */ +/* Toggle label for auto-add contacts */ +"nodes.autoAddContacts" = "Contactos"; + +/* Toggle label for auto-add repeaters */ +"nodes.autoAddRepeaters" = "Repetidores"; + +/* Toggle label for auto-add room servers */ +"nodes.autoAddRoomServers" = "Servidores de sala"; + +/* Section header for storage settings */ +/* Toggle label for overwrite oldest */ +"nodes.overwriteOldest" = "Substituir o mais antigo"; + +/* Description for overwrite oldest toggle */ +"nodes.overwriteOldestDescription" = "Quando o armazenamento estiver cheio, substituir o nó não favorito mais antigo"; + +/* Picker label for max hop distance */ +"nodes.maxHops" = "Limite máximo de saltos na adição automática"; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Sem limite"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Apenas direto"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 salto"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d saltos"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Os nós acima do limite de saltos selecionado não serão adicionados automaticamente."; + +// MARK: - Auto-Remove Old Nodes Section + +/* Toggle label / section header for stale node cleanup */ +"nodes.staleCleanup.header" = "Remover automaticamente nós antigos"; + +/* Label for threshold picker */ +"nodes.staleCleanup.threshold" = "Remover nós mais antigos do que"; + +/* Picker placeholder when no threshold is selected */ +"nodes.staleCleanup.select" = "Selecionar"; + +/* Threshold option: number of days (%d = day count) */ +"nodes.staleCleanup.days" = "%d dias"; + +/* Footer when auto-remove is enabled (%d = day count) */ +"nodes.staleCleanup.footerEnabled" = "Os nós não favoritos sem alterações em %d dias são removidos automaticamente ao estabelecer ligação."; + +/* Footer describing the auto-remove feature (shown when toggle is off) */ +"nodes.staleCleanup.footerDisabled" = "Remove automaticamente nós não favoritos sem alterações durante o período definido. Os favoritos nunca são removidos."; + +/* Footer when toggle is on but no threshold selected yet */ +"nodes.staleCleanup.footerSelect" = "Escolha durante quanto tempo manter os nós. Os favoritos nunca são removidos."; + +/* Footer when enabled but device is disconnected */ +"nodes.staleCleanup.footerDisconnected" = "Irá verificar nós antigos ao estabelecer ligação. Os favoritos nunca são removidos."; + +/* Last cleanup date row (%@ = relative date) */ +"nodes.staleCleanup.lastRun" = "Última verificação %@"; + +// MARK: - Device Actions Section + +/* Section header for device actions */ +"deviceActions.header" = "Dispositivo"; + +/* Button to reboot the device */ +"deviceActions.rebootDevice" = "Reiniciar dispositivo"; + +/* Text shown while rebooting */ +"deviceActions.rebooting" = "A reiniciar…"; + +/* Alert title for reboot confirmation */ +"deviceActions.alert.reboot.title" = "Reiniciar o dispositivo?"; + +/* Button to confirm reboot */ +"deviceActions.alert.reboot.confirm" = "Reiniciar"; + +/* Alert message for reboot confirmation */ +"deviceActions.alert.reboot.message" = "O dispositivo irá reiniciar e a ligação será interrompida temporariamente."; + +// MARK: - Danger Zone Section + +/* Section header for danger zone */ +"dangerZone.header" = "Zona perigosa"; + +/* Button to forget/unpair the device */ +"dangerZone.forgetDevice" = "Esquecer dispositivo"; + +/* Button to factory reset the device */ +"dangerZone.factoryReset" = "Repor definições de fábrica"; + +/* Text shown while resetting */ +"dangerZone.resetting" = "A repor…"; + +/* Footer explaining factory reset */ +"dangerZone.footer" = "A reposição de fábrica apaga todos os contactos, mensagens e definições no dispositivo."; + +/* Confirmation dialog title for forget device */ +"dangerZone.dialog.forget.title" = "Esquecer dispositivo"; + +/* Confirmation dialog message for forget device */ +"dangerZone.dialog.forget.message" = "O dispositivo será desemparelhado. É possível escolher manter ou eliminar as mensagens, os contactos e os canais."; + +/* Button to forget device but keep data */ +"dangerZone.dialog.forget.keepData" = "Esquecer dispositivo"; + +/* Button to forget device and delete all associated data */ +"dangerZone.dialog.forget.deleteAll" = "Esquecer dispositivo e eliminar dados"; + +/* Alert title for factory reset confirmation */ +"dangerZone.alert.reset.title" = "Reposição de fábrica"; + +/* Button to confirm reset */ +"dangerZone.alert.reset.confirm" = "Repor"; + +/* Alert message for factory reset */ +"dangerZone.alert.reset.message" = "Isto irá apagar todos os dados do dispositivo, incluindo contactos, mensagens e definições. Toque em Remover quando for pedido para desemparelhar o dispositivo."; + +/* Error when services are not available */ +"dangerZone.error.servicesUnavailable" = "Serviços indisponíveis"; + +/* Button to remove non-favorite nodes */ +"dangerZone.removeUnfavorited" = "Remover nós não favoritos"; + +/* Text shown while removing unfavorited nodes */ +"dangerZone.removing" = "A remover…"; + +/* Label shown after successful removal */ +"dangerZone.removed" = "Nós removidos"; + +/* Alert title for remove non-favorite confirmation */ +"dangerZone.alert.removeUnfavorited.title" = "Remover nós não favoritos"; + +/* Button to confirm removal */ +"dangerZone.alert.removeUnfavorited.confirm" = "Remover nós"; + +/* Alert title for removal result */ +"dangerZone.alert.removeUnfavorited.resultTitle" = "Resultado da remoção"; + +/* Alert message for remove unfavorited (%d = count) */ +"dangerZone.alert.removeUnfavorited.message" = "Isto irá eliminar permanentemente do dispositivo e da app %d nós não marcados como favoritos, juntamente com as respetivas mensagens."; + +/* Partial success message (%d = removed, %d = total) */ +"dangerZone.alert.removeUnfavorited.partial" = "Removidos %d de %d nós. A ligação foi interrompida. Toque novamente no botão para tentar remover os nós restantes."; + +/* Message when no non-favorite nodes to remove */ +"dangerZone.alert.removeUnfavorited.noneFound" = "Não há nós não favoritos para remover."; + +// MARK: - Diagnostics Section + +/* Section header for diagnostics */ +"diagnostics.header" = "Diagnóstico"; + +/* Button to export debug logs */ +"diagnostics.exportLogs" = "Exportar registos de depuração"; + +/* Button to clear debug logs */ +"diagnostics.clearLogs" = "Limpar registos de depuração"; + +/* Footer explaining log export */ +"diagnostics.footer" = "A exportação inclui os registos de depuração de todas as sessões da app. Os registos são guardados localmente e removidos automaticamente ao fim de 7 dias."; + +/* Alert title for clear logs confirmation */ +"diagnostics.alert.clear.title" = "Limpar registos de depuração"; + +/* Button to confirm clear */ +"diagnostics.alert.clear.confirm" = "Limpar"; + +/* Alert message for clear logs */ +"diagnostics.alert.clear.message" = "Isto irá eliminar todos os registos de depuração armazenados. Os ficheiros de registo já exportados não serão afetados."; + +/* Error when export fails */ +"diagnostics.error.exportFailed" = "Não foi possível criar o ficheiro de exportação"; + +// MARK: - Chat Settings + +/* Navigation title for chat settings page */ +"chatSettings.title" = "Chats"; + +// MARK: - Link Preview Settings Section + +/* Section header for link content settings */ +"linkPreviews.header" = "Conteúdo de ligações"; + +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Mostrar conteúdo de ligações"; + +/* Toggle label for showing link content in DMs */ +"linkPreviews.showInDMs" = "Mostrar em mensagens diretas"; + +/* Toggle label for showing link content in channels */ +"linkPreviews.showInChannels" = "Mostrar em canais"; + +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "As pré-visualizações de ligações e as imagens são obtidas através da ligação à Internet do telemóvel, não da mesh. Isto pode revelar o endereço IP ao servidor que aloja o conteúdo e pode usar dados móveis."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "A opção Reduzir movimento está ativada, por isso os GIFs não são reproduzidos automaticamente."; + +// MARK: - Inline Image Settings Section + +/* Toggle label for auto-play GIFs */ +"inlineImages.autoPlayGifs" = "Reprodução automática de GIFs"; + +// MARK: - Map Preview Settings Section + +/* Section header for chat map preview settings */ +"mapPreviews.header" = "Pré-visualizações do mapa"; + +/* Toggle label for chat map thumbnails */ +"mapPreviews.toggle" = "Mostrar miniaturas do mapa"; + +/* Footer explaining map preview privacy implications */ +"mapPreviews.footer" = "Mostrar miniaturas do mapa vai buscar os mosaicos dessa coordenada a um servidor de terceiros, o que pode revelar o seu endereço IP."; + +// MARK: - Node Settings Section + +/* Section header for node settings */ +"node.header" = "Nó"; + +/* Label for node name */ +"node.name" = "Nome do nó"; + +/* Default node name when unknown */ +/* Button text to copy */ +/* Label for set location button */ +"node.setLocation" = "Definir localização"; + +/* Text shown when location is set */ +"node.locationSet" = "Definida"; + +/* Text shown when location is not set */ +"node.locationNotSet" = "Não definida"; + +/* Toggle label for share location publicly */ +"node.shareLocationPublicly" = "Partilhar localização publicamente"; + +/* Footer explaining node visibility */ +"node.footer" = "O nome do nó fica visível para outros utilizadores da mesh quando é partilhado."; + +/* Alert title for editing node name */ +"node.alert.editName.title" = "Editar nome do nó"; + +// MARK: - Location Settings Section + +/* Section header for location settings */ +"location.header" = "Localização"; + +/* Footer for location settings section */ +"location.footer" = "Com a opção «Partilhar localização publicamente» ativada, a localização é transmitida na mesh ao enviar Adverts. A opção «Atualizar localização automaticamente» atualiza a posição do dispositivo antes de cada Advert."; + +/* Toggle label for auto-update location */ +"location.autoUpdate" = "Atualizar localização automaticamente"; + +/* Label for GPS source picker */ +"location.gpsSource" = "Fonte de GPS"; + +/* GPS source option: phone GPS */ +"location.gpsSource.phone" = "GPS do telemóvel"; + +/* GPS source option: device GPS */ +"location.gpsSource.device" = "GPS do dispositivo"; + +/* Section header for device GPS controls */ +"location.deviceGps.header" = "GPS do dispositivo"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Ativar GPS do dispositivo"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Liga ou desliga o GPS integrado do rádio. Guardar uma localização manual no mapa desliga o GPS do dispositivo."; + +/* Detail text when location is being shared publicly */ +"location.sharingPublicly" = "Partilhada publicamente"; + +/* Detail text when location is not being shared */ +"location.notSharing" = "Não partilhada"; + +// MARK: - Notification Settings Section + +/* Section header for notifications */ +"notifications.header" = "Notificações"; + +/* Toggle label for contact messages notifications */ +"notifications.contactMessages" = "Mensagens de contactos"; + +/* Toggle label for channel messages notifications */ +"notifications.channelMessages" = "Mensagens de canais"; + +/* Toggle label for room messages notifications */ +"notifications.roomMessages" = "Mensagens de salas"; + +/* Toggle label for new contact discovered notifications */ +"notifications.newContactDiscovered" = "Novo contacto descoberto"; + +/* Toggle label for companion discovery notifications */ +"notifications.discoveryContact" = "Contacto"; + +/* Toggle label for repeater discovery notifications */ +"notifications.discoveryRepeater" = "Repetidor"; + +/* Toggle label for room discovery notifications */ +"notifications.discoveryRoom" = "Sala"; + +/* Toggle label for low battery warnings */ +"notifications.lowBattery" = "Avisos de bateria fraca"; + +/* Toggle label for reaction notifications */ +"notifications.reactions" = "Reações"; + +/* Button to enable notifications */ +"notifications.enable" = "Ativar notificações"; + +/* Label shown when notifications are disabled */ +"notifications.disabled" = "Notificações desativadas"; + +/* Button to open system settings */ +"notifications.openSettings" = "Abrir Definições"; + +/* Message shown when device not connected */ +/* Footer for notifications navigation row */ +// MARK: - Radio Preset Section + +/* Section header for radio settings */ +"radio.header" = "Rádio"; + +/* Label for radio preset picker */ +"radio.preset" = "Predefinição de rádio"; + +/* Footer explaining radio presets */ +"radio.footer" = "Escolha uma predefinição correspondente à sua região. Os dispositivos MeshCore têm de usar as mesmas definições de rádio para comunicar."; + +/* Toggle label for repeat mode */ +"radio.repeatMode" = "Modo de repetidor"; + +/* Footer explaining repeat mode */ +"radio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para zonas remotas."; + +/* Accessibility hint for repeat mode toggle */ +"radio.repeatMode.accessibilityHint" = "Ativar isto irá desligar da rede mesh principal"; + +/* Confirmation dialog title for enabling repeat mode */ +"radio.repeatMode.confirm.title" = "Ativar o modo de repetidor?"; + +/* Confirmation dialog message for enabling repeat mode */ +"radio.repeatMode.confirm.message" = "Será desligado da rede mesh principal. Só serão alcançáveis dispositivos na mesma frequência do modo de repetidor."; + +/* Confirmation dialog enable button */ +"radio.repeatMode.confirm.enable" = "Ativar"; + +/* Location: RadioPresetSection.swift - Footer line listing the user's region */ +"radio.regionFooter" = "A mostrar as predefinições recomendadas para %@."; + +/* Location: RadioPresetSection.swift - Footer warning when current preset isn't recommended for region */ +"radio.mismatchHint" = "O rádio está em %@, não na predefinição recomendada para %@."; + +// MARK: - Telemetry Settings Section + +/* Section header for telemetry settings */ +"telemetry.header" = "Telemetria"; + +/* Toggle label for allowing telemetry requests */ +"telemetry.allowRequests" = "Permitir pedidos de telemetria"; + +/* Description for telemetry requests toggle */ +"telemetry.allowRequestsDescription" = "Necessário para que outros utilizadores possam traçar manualmente uma rota até si. Partilha o nível da bateria."; + +/* Toggle label for including location in telemetry */ +"telemetry.includeLocation" = "Incluir localização"; + +/* Description for include location toggle */ +"telemetry.includeLocationDescription" = "Partilhar coordenadas GPS na telemetria"; + +/* Toggle label for including environment sensors */ +"telemetry.includeEnvironment" = "Incluir sensores ambientais"; + +/* Description for include environment toggle */ +"telemetry.includeEnvironmentDescription" = "Partilhar temperatura, humidade, etc."; + +/* Toggle label for trusted contacts only */ +"telemetry.trustedOnly" = "Partilhar apenas com contactos de confiança"; + +/* Description for trusted contacts toggle */ +"telemetry.trustedOnlyDescription" = "Limitar a telemetria aos contactos selecionados"; + +/* Link to manage trusted contacts */ +"telemetry.manageTrusted" = "Gerir contactos de confiança"; + +/* Footer explaining telemetry */ +"telemetry.footer" = "Quando ativado, outros nós podem pedir os dados de telemetria do dispositivo."; + +// MARK: - WiFi Section + +/* Section header for WiFi settings */ +"wifi.header" = "Wi-Fi"; + +/* Label for IP address */ +"wifi.address" = "Endereço"; + +/* Label for port number */ +"wifi.port" = "Porta"; + +/* Button to edit WiFi connection */ +"wifi.editConnection" = "Editar ligação"; + +/* Footer explaining WiFi address */ +"wifi.footer" = "Endereço de rede local do dispositivo"; + +// MARK: - WiFi Edit Sheet + +/* Navigation title for WiFi edit sheet */ +"wifiEdit.title" = "Editar ligação Wi-Fi"; + +/* Section header for connection details */ +"wifiEdit.connectionDetails" = "Detalhes da ligação"; + +/* Placeholder for IP address field */ +/* Placeholder for port field */ +/* Accessibility label for clear IP button */ +/* Accessibility label for clear port button */ +/* Footer explaining reconnection */ +"wifiEdit.footer" = "Alterar estes valores irá desligar e voltar a ligar ao novo endereço."; + +/* Text shown while reconnecting */ +"wifiEdit.reconnecting" = "A restabelecer ligação…"; + +/* Button to save changes */ +"wifiEdit.saveChanges" = "Guardar alterações"; + +/* Error for invalid port */ +"wifiEdit.error.invalidPort" = "Número de porta inválido"; + +// MARK: - Error Alert + +/* Alert title for generic errors */ +"alert.error.title" = "Erro"; + +// MARK: - Retry Alert + +/* Alert title when max retries exceeded */ +"alert.retry.unableToSave" = "Não foi possível guardar a definição"; + +/* Alert title for connection errors */ +"alert.retry.connectionError" = "Erro de ligação"; + +/* Button to retry the operation */ +"alert.retry.retry" = "Tentar novamente"; + +/* Alert message when max retries exceeded */ +"alert.retry.ensureConnected" = "Certifique-se de que o dispositivo está ligado."; + +/* Fallback message for retry alerts when error description is unavailable */ +"alert.retry.fallbackMessage" = "Certifique-se de que o dispositivo está ligado e tente novamente."; + +// MARK: - Battery Curve Chart + +/* Chart X axis label */ +"chart.percent" = "Percentagem"; + +/* Chart Y axis label */ +"chart.voltage" = "Tensão (V)"; + +/* Accessibility label for battery curve chart */ +"chart.accessibility" = "Curva de descarga da bateria com a tensão em cada nível de percentagem"; + +// MARK: - Messages Settings Section + +/* Section header for message info settings in chats */ +"messages.header" = "Informações da mensagem"; + +/* Toggle label for showing the raw uncorrected wire send time on incoming messages */ +/* Toggle label for showing routing path on incoming messages */ +"messages.showIncomingPath" = "Rota"; + +/* Toggle label for showing hop count on incoming messages */ +"messages.showIncomingHopCount" = "Número de saltos"; + +/* Toggle label for showing the radio region an incoming message was flooded under */ +"messages.showIncomingRegion" = "Região"; + +/* Footer explaining what the message display options show */ +"messages.footer" = "Mostra informações de encaminhamento e de tempo nos balões das mensagens recebidas."; + +// MARK: - Direct Messages Settings Section + +/* Section header for direct message settings */ +"directMessages.header" = "Mensagens diretas"; + +/* Picker label for number of acknowledgments */ +"directMessages.acknowledgments" = "Confirmações"; + +/* Footer explaining the acknowledgments setting */ +"directMessages.footer" = "Número de confirmações enviadas por mensagem direta. Use 2 para melhor confirmação de entrega em ligações pouco fiáveis."; + +// MARK: - BLE Status Indicator + +/* Menu item to send a zero-hop advertisement */ +"bleStatus.sendZeroHopAdvert" = "Enviar Advert zero-hop"; + +/* Menu item to send a flood advertisement */ +"bleStatus.sendFloodAdvert" = "Enviar Advert por difusão"; + +/* Menu item to change the connected device */ +"bleStatus.changeDevice" = "Mudar de dispositivo"; + +/* Menu item to disconnect from the current device */ +"bleStatus.disconnect" = "Desligar"; + +/* Status shown when device is disconnected */ +"bleStatus.status.disconnected" = "Sem ligação"; + +/* Status shown when device is connecting */ +"bleStatus.status.connecting" = "A ligar…"; + +/* Status shown when device is connected but not ready */ +"bleStatus.status.connected" = "Ligado"; + +/* Status shown when device is syncing data */ +"bleStatus.status.syncing" = "A sincronizar"; + +/* Status shown when device is ready */ +"bleStatus.status.ready" = "Pronto"; + +/* Accessibility label for BLE status indicator */ +"bleStatus.accessibilityLabel" = "Estado da ligação Bluetooth"; + +/* Accessibility hint when disconnected */ +"bleStatus.accessibilityHint.disconnected" = "Toque duas vezes para ligar o dispositivo"; + +/* Accessibility hint when connected */ +"bleStatus.accessibilityHint.connected" = "Mostra as opções de ligação do dispositivo"; + +/* Accessibility hint for zero-hop advert button */ +"bleStatus.sendZeroHopAdvert.hint" = "Transmite apenas para vizinhos diretos"; + +/* Accessibility hint for flood advert button */ +"bleStatus.sendFloodAdvert.hint" = "Propaga o Advert por toda a mesh"; + +/* Label shown in BLE status menu when repeat mode is active */ +"bleStatus.repeatModeActive" = "Modo de repetidor ativo"; + +// MARK: - Config Export/Import + +/* Section title for config export/import */ +"configExport.sectionTitle" = "Configuração do dispositivo"; + +/* Section footer explaining config export/import */ +"configExport.sectionFooter" = "Exporte ou importe definições do dispositivo, canais e contactos através de um ficheiro JSON."; + +/* Export navigation row label */ +"configExport.export" = "Exportar config"; + +/* Export screen navigation title */ +"configExport.title" = "Exportar configuração"; + +/* Export full config button */ +"configExport.exportFull" = "Exportar configuração completa"; + +/* Export selected sections button */ +"configExport.exportSelected" = "Exportar secções selecionadas"; + +/* Customize export disclosure label */ +/* Select all toggle label */ +"configExport.selectAll" = "Selecionar tudo"; + +/* Toggle labels for export sections */ +"configExport.nodeIdentity" = "Identidade do nó (nome e chaves)"; +"configExport.nodeIdentity.description" = "Nome do dispositivo, chave pública e chave privada"; +"configExport.radioSettings" = "Definições do rádio"; +"configExport.radioSettings.description" = "Frequência, largura de banda, fator de espalhamento, taxa de codificação, potência de TX"; +"configExport.positionSettings" = "Posição"; +"configExport.positionSettings.description" = "Latitude e longitude"; +"configExport.otherSettings" = "Outras definições"; +"configExport.otherSettings.description" = "Permissões de contactos, partilha de localização"; +"configExport.channels" = "Canais"; +"configExport.channels.description" = "Todos os canais e as respetivas chaves de encriptação"; +"configExport.contacts" = "Contactos"; +"configExport.contacts.description" = "Nomes, chaves e últimas posições conhecidas"; + +// MARK: - Config Import + +/* Import screen navigation title */ +"configImport.title" = "Importar configuração"; + +/* Select file button */ +"configImport.selectFile" = "Selecionar ficheiro JSON"; + +/* Confirmation alert title */ +"configImport.confirmTitle" = "Aplicar a configuração?"; + +/* Confirmation alert message (param: device name) */ +"configImport.confirmMessage" = "Isto irá fundir as definições selecionadas com %@. Os canais e contactos existentes serão atualizados ou adicionados."; + +/* Apply button label */ +"configImport.applyButton" = "Aplicar ao dispositivo"; + +/* Apply button label (additive only — channels/contacts) */ +"configImport.applyButtonAdd" = "Adicionar ao dispositivo"; + +/* Apply button label (overwrite settings) */ +"configImport.applyButtonOverwrite" = "Substituir definições"; + +/* Confirmation alert title (additive only) */ +"configImport.confirmTitleAdd" = "Adicionar ao dispositivo?"; + +/* Confirmation alert title (overwrite settings) */ +"configImport.confirmTitleOverwrite" = "Substituir as definições?"; + +/* Confirmation alert message (additive only, param: device name) */ +"configImport.confirmMessageAdd" = "Isto irá adicionar canais e contactos a %@. Os dados existentes não serão removidos."; + +/* Confirmation alert message (overwrite only, param: device name) */ +"configImport.confirmMessageOverwrite" = "Isto irá substituir as definições selecionadas (%@). Os valores atuais serão substituídos."; + +/* Confirmation alert message (mixed, param: device name) */ +"configImport.confirmMessageMixed" = "Isto irá substituir as definições e adicionar canais/contactos (%@)."; + +/* Proximity warning */ +"configImport.proximityWarning" = "Mantenha os dispositivos próximos durante a importação"; + +/* Import cancelled */ +"configImport.cancelled" = "A importação foi cancelada."; + +/* Import cancelled after one or more settings were already written to the device */ +"configImport.cancelledPartial" = "Importação cancelada, mas algumas definições já tinham sido gravadas no dispositivo. Reveja a configuração do dispositivo."; + +/* Import failed after one or more settings were already written; %@ is the underlying error message */ +"configImport.failedPartial" = "%@ Algumas definições podem já ter sido gravadas no dispositivo. Reveja a configuração do dispositivo."; + +/* Import success */ +"configImport.importSuccess" = "Configuração importada com êxito"; + +/* Private key warning */ +"configImport.privateKeyWarning" = "Isto irá substituir a identidade criptográfica do nó"; + +"configImport.current" = "Atual: %@"; +"configImport.new" = "Novo: %@"; + +/* Channel count (param: integer) */ +"configImport.channelCount" = "Irá adicionar/atualizar %d canais no dispositivo"; + +/* Import navigation row label */ +"configImport.importConfig" = "Importar config"; + +/* Radio settings change warning */ +"configImport.radioWarning" = "Alterar as definições de rádio irá desligar da rede mesh atual"; + +/* Contact count (param: integer) */ +"configImport.contactCount" = "Irá adicionar/atualizar %d contactos no dispositivo"; + +/* Generic device-name fallback used in confirmation messages when the node has no name */ +"configImport.thisDevice" = "este dispositivo"; + +/* Import progress: applying position */ +"configImport.stepPosition" = "A definir a posição"; + +/* Import progress: applying other parameters */ +"configImport.stepOtherParameters" = "A definir outros parâmetros"; + +/* Import progress: importing the private key */ +"configImport.stepPrivateKey" = "A importar a chave privada"; + +/* Import progress: setting the node name */ +"configImport.stepNodeName" = "A definir o nome do nó"; + +/* Import progress: applying radio parameters */ +"configImport.stepRadioParameters" = "A definir os parâmetros do rádio"; + +/* Import progress: setting TX power */ +"configImport.stepTxPower" = "A definir a potência de TX"; + +/* Import progress: importing a channel (param: channel name) */ +"configImport.stepChannel" = "A importar o canal: %@"; + +/* Import progress: importing a contact (param: contact name) */ +"configImport.stepContact" = "A importar o contacto: %@"; + +/* Validation field label: radio frequency */ +"configImport.field.frequency" = "Frequência"; + +/* Validation field label: radio bandwidth */ +"configImport.field.bandwidth" = "Largura de banda (BW)"; + +/* Validation field label: spreading factor */ +"configImport.field.spreadingFactor" = "Fator de espalhamento (SF)"; + +/* Validation field label: coding rate */ +"configImport.field.codingRate" = "Taxa de codificação (CR)"; + +/* Validation field label: TX power */ +"configImport.field.txPower" = "Potência de TX"; + +/* Validation field label: latitude */ +"configImport.field.latitude" = "Latitude"; + +/* Validation field label: longitude */ +"configImport.field.longitude" = "Longitude"; + +/* Validation error: value outside radio's supported range (param: field label) */ +"configImport.error.radioOutOfRange" = "%@ está fora do intervalo suportado pelo rádio"; + +/* Validation error: invalid coordinate (param: field label) */ +"configImport.error.positionInvalid" = "%@ tem uma coordenada inválida ou fora do intervalo"; + +/* Validation error: contact coordinate invalid (params: contact name, field label) */ +"configImport.error.contactCoordinateInvalid" = "O contacto \"%@\" %@ tem uma coordenada inválida ou fora do intervalo"; + +/* Validation error: contact has an invalid routing path (param: contact name) */ +"configImport.error.invalidOutPath" = "O contacto \"%@\" tem uma rota inválida"; + +/* Validation error: not enough free contact slots (params: needed count, available count) */ +"configImport.error.contactCapacityExceeded" = "A importação precisa de %d slot(s) de contacto livre(s), mas restam apenas %d no dispositivo"; + +/* Validation error: channel secret wrong length (params: channel index, hex char count) */ +"configImport.error.invalidChannelSecret" = "O canal %1$lld tem um secret inválido (%2$lld caracteres hex, esperados 32)"; + +/* Validation error: contact public key malformed (param: contact name) */ +"configImport.error.invalidContactPublicKey" = "O contacto \"%@\" tem uma chave pública inválida"; + +/* Validation error: unsupported path hash mode (params: contact name, mode value) */ +"configImport.error.invalidPathHashMode" = "O contacto \"%1$@\" tem um modo de hash da rota inválido %2$lld (esperado 0, 1 ou 2)"; + +/* Validation error: private key wrong length (params: hex char count, expected hex char count) */ +"configImport.error.invalidPrivateKey" = "Chave privada inválida (%1$lld caracteres hex, esperados %2$lld)"; + +/* Validation error: no empty channel slot left (param: channel name) */ +"configImport.error.noAvailableChannelSlot" = "Nenhum slot de canal vazio disponível para \"%@\""; + +// MARK: - Blocking Section + +/* Location: BlockingSection.swift - Purpose: Section header */ +"blocking.header" = "Bloqueio"; + +/* Location: BlockingSection.swift - Purpose: Channel senders row */ +"blocking.channelSenders" = "Remetentes de canal"; + +/* Location: BlockingSection.swift - Purpose: Contacts row */ +"blocking.contacts" = "Contactos"; + +/* Location: BlockedChannelSendersView.swift - Purpose: Navigation title */ +"blocking.channelSenders.title" = "Remetentes de canal bloqueados"; + +/* Location: BlockedChannelSendersView.swift - Purpose: Empty state title */ +"blocking.channelSenders.empty.title" = "Nenhum remetente bloqueado"; + +/* Location: BlockedChannelSendersView.swift - Purpose: Empty state description */ +"blocking.channelSenders.empty.description" = "Os nomes de remetentes de canal que bloquear aparecem aqui."; + +/* Location: BlockedChannelSendersView.swift - Purpose: Swipe action to unblock */ +// MARK: - Language + +/* Location: SettingsView.swift - Purpose: Language row title */ +"language.title" = "Idioma"; + +// MARK: - Region + +/* Location: RegionSettingsView.swift - Title */ +/* Location: SettingsView.swift - Detail row when region is unset */ +/* Location: RegionalAreas.subdivisionDisplayName - California (US-CA) state name */ +"region.subdivision.US-CA" = "Califórnia"; + +/* Location: RegionalAreas.subdivisionDisplayName - Queensland (AU-QLD) state name */ +"region.subdivision.AU-QLD" = "Queensland"; + +/* Location: RegionalAreas.subdivisionDisplayName - South Australia (AU-SA) state name */ +"region.subdivision.AU-SA" = "Austrália do Sul"; + +/* Location: RegionalAreas.subdivisionDisplayName - Western Australia (AU-WA) state name */ +"region.subdivision.AU-WA" = "Austrália Ocidental"; + +// MARK: - Live Activity + +/* Label for the Live Activity toggle in App Settings */ +"liveActivity.title" = "Atividade em tempo real"; + +/* Title for the Live Activity tip shown after first connection */ +"liveActivity.tip.title" = "Estado do rádio num relance"; + +/* Message for the Live Activity tip */ +"liveActivity.tip.message" = "A ligação, a bateria e as mensagens ficam visíveis, mesmo sem abrir a app."; + +// MARK: - Regenerate Identity + +/* Section header for regenerate identity */ +"regenerateIdentity.header" = "Identidade"; + +/* Button label to open regenerate identity sheet */ +"regenerateIdentity.title" = "Regenerar chave"; + +/* Navigation title for the regenerate identity sheet */ +"regenerateIdentity.sheet.title" = "Regenerar chave"; + +/* Explanation of what regenerating identity does */ +"regenerateIdentity.sheet.explanation" = "Gere um novo par de chaves Ed25519 para substituir a identidade atual do dispositivo. Todos os contactos terão de voltar a descobrir o dispositivo."; + +/* Button to generate a new key */ +"regenerateIdentity.generate" = "Gerar chave"; + +/* Button to generate another key after the first */ +/* Progress label while generating */ +"regenerateIdentity.generating" = "A gerar…"; + +/* Button to apply the generated key to the device */ +"regenerateIdentity.replace" = "Usar esta chave"; + +/* Progress label while importing key to device */ +"regenerateIdentity.importing" = "A importar…"; + +/* Disclosure group label for custom prefix */ +"regenerateIdentity.prefix.label" = "Prefixo personalizado"; + +/* Placeholder for prefix text field */ +"regenerateIdentity.prefix.placeholder" = "ex.: 2BA1"; + +/* Footer explaining vanity prefix */ +"regenerateIdentity.prefix.footer" = "Opcionalmente, especifique até 4 caracteres hex com os quais a chave pública deve começar. Prefixos mais longos demoram mais tempo."; + +/* Error when prefix is not 1–4 hex characters */ +/* Error when prefix is 00 or FF */ +"regenerateIdentity.prefix.error.reserved" = "00 e FF são reservados e não podem ser usados"; + +/* Alert title for replace identity confirmation */ +"regenerateIdentity.alert.replace.title" = "Substituir a identidade?"; + +/* Alert confirm button for replace identity */ +"regenerateIdentity.alert.replace.confirm" = "Substituir"; + +/* Alert message for replace identity */ +"regenerateIdentity.alert.replace.message" = "Isto irá substituir permanentemente a identidade do dispositivo. Os outros dispositivos irão vê-lo como um contacto novo."; + +/* Error when firmware does not support key import */ +"regenerateIdentity.error.featureDisabled" = "A importação de chaves não é suportada por esta versão de firmware"; + +/* Error when device rejects the key */ +"regenerateIdentity.error.deviceRejected" = "O dispositivo rejeitou a chave. Tente novamente."; + +// MARK: - Import Key + +/* Button label to open import key sheet */ +"importKey.title" = "Importar chave"; + +/* Navigation title for the import key sheet */ +"importKey.sheet.title" = "Importar chave"; + +/* Explanation of what importing a key does */ +"importKey.sheet.explanation" = "Cole uma chave privada Ed25519 existente (128 caracteres hex) para substituir a identidade atual do dispositivo."; + +/* Label for the private key input field */ +"importKey.keyInput.label" = "Chave privada"; + +/* Placeholder for the key input field */ +"importKey.keyInput.placeholder" = "Cole a chave hex de 128 caracteres"; + +/* Button to import the key */ +"importKey.import" = "Importar chave"; + +/* Progress label while importing */ +"importKey.importing" = "A importar…"; + +/* Error when hex input is wrong length or invalid */ +"importKey.error.invalidHex" = "A chave tem de ter exatamente 128 caracteres hex"; + +/* Error when key fails Ed25519 validation */ +"importKey.error.invalidKey" = "Não é uma chave privada Ed25519 válida"; + +// Reply with Quote +"replyWithQuote.toggle" = "Responder com citação"; +"replyWithQuote.footer" = "A resposta inclui uma pré-visualização da mensagem original."; + +// MARK: - Maps + +/* Navigation title for Settings → Maps hub */ +"maps.title" = "Mapas"; + +/* Section header for display preferences on Maps settings */ +"maps.displayHeader" = "Visualização"; + +/* Picker label for map basemap appearance */ +"maps.appearance" = "Aspeto do mapa"; + +/* Footer explaining map appearance is basemap-only */ +"maps.appearanceFooter" = "Controla apenas o mapa base. As cores da app continuam a seguir Aspeto."; + +/* Section header for Offline Maps entry under Maps settings */ +"maps.offlineHeader" = "Mapas offline"; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Mapas offline"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Nenhum mapa offline"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Transfira regiões do mapa para usar sem Internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Transferir região"; + +/* Section header for storage info */ +"offlineMaps.storage" = "Armazenamento"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Armazenamento usado"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Região desconhecida"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Transferido"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "A transferir…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Selecionar região"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nome da região"; + +/* Button to start download */ +"offlineMaps.download" = "Transferir"; + +/* Cancel button */ +"offlineMaps.cancel" = "Cancelar"; + +/* Delete confirmation title */ +/* Delete confirmation message */ +/* Delete button */ +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Não há espaço de armazenamento suficiente. São necessários pelo menos 100 MB."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Foi atingido o limite de mosaicos de transferência."; + +/* Pause download button */ +"offlineMaps.pause" = "Pausa"; + +/* Resume download button */ +"offlineMaps.resume" = "Retomar"; + +/* Paused status label */ +"offlineMaps.paused" = "Em pausa"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Tamanho estimado: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Área de transferência grande. Isto pode demorar e usar bastante armazenamento."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Introduza um nome e selecione uma área para transferir."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Não há armazenamento suficiente neste dispositivo. Aumente o zoom para selecionar uma área menor."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Mapa base"; +"offlineMaps.layer.topo" = "Topografia"; + +/* Layers section header */ +"offlineMaps.layers" = "Camadas"; + +/* Include layers prompt */ +/* No network available */ +"offlineMaps.noNetwork" = "É necessária uma ligação à Internet para transferir mapas."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Inclui dados do mapa e índices internos. O total pode ser maior do que a soma das transferências individuais."; + +// MARK: - Backup & Restore + +/* Navigation title for backup & restore screen */ +"settings.backup.title" = "Backup e restauro"; + +/* Section header for file backup */ +"settings.backup.file_backup.header" = "Backup em ficheiro"; + +/* Section footer for file backup */ +"settings.backup.file_backup.footer" = "Exporte ou restaure mensagens, contactos, canais, rotas guardadas e definições. A configuração do rádio é lida do dispositivo em cada ligação."; + +/* Export row title */ +"settings.backup.export.title" = "Exportar dados da app"; + +/* Export row subtitle */ +"settings.backup.export.subtitle" = "Guardar num ficheiro .mc1backup"; + +/* Import row title */ +"settings.backup.import.title" = "Importar dados da app"; + +/* Import row subtitle */ +"settings.backup.import.subtitle" = "Restaurar a partir de um ficheiro .mc1backup"; + +/* Footer shown above the backup section when a radio is connected, explaining why import is disabled */ +"settings.backup.import.disabled_when_connected" = "Termine a ligação ao rádio antes de importar um backup."; + +/* Export confirmation alert title */ +"settings.backup.export.alert.title" = "Aviso de segurança"; + +/* Export confirmation alert message */ +"settings.backup.export.alert.message" = "Este backup inclui as chaves de encriptação dos canais, o histórico de mensagens, a lista de contactos, o endereço de rede local e a porta do nó Wi-Fi. Guarde o ficheiro exportado de forma segura."; + +/* Export confirmation alert export button */ +"settings.backup.export.alert.export" = "Exportar"; + +/* Export confirmation alert cancel button */ +"settings.backup.export.alert.cancel" = "Cancelar"; + +/* Export progress label */ +"settings.backup.export.progress" = "A preparar o backup…"; + +/* Import preview navigation title */ +"settings.backup.import.preview.title" = "Pré-visualização da importação"; + +/* Import preview section header: backup details */ +"settings.backup.import.preview.details" = "Detalhes do backup"; + +/* Import preview label: export date */ +"settings.backup.import.preview.exported" = "Data de exportação"; + +/* Import preview label: app version */ +"settings.backup.import.preview.app_version" = "Versão da app"; + +/* Import preview section header: contents */ +"settings.backup.import.preview.contents" = "Conteúdo"; + +/* Import preview manifest label: messages */ +"settings.backup.import.preview.messages" = "Mensagens"; + +/* Import preview manifest label: contacts */ +"settings.backup.import.preview.contacts" = "Contactos"; + +/* Import preview manifest label: channels */ +"settings.backup.import.preview.channels" = "Canais"; + +/* Import preview manifest label: devices */ +"settings.backup.import.preview.devices" = "Dispositivos"; + +/* Import preview manifest label: room messages */ +"settings.backup.import.preview.room_messages" = "Mensagens de sala"; + +/* Import preview manifest label: reactions */ +"settings.backup.import.preview.reactions" = "Reações"; + +/* Import preview manifest label: saved paths */ +"settings.backup.import.preview.saved_paths" = "Rotas guardadas"; + +/* Import preview manifest label: remote node sessions */ +"settings.backup.import.preview.remote_node_sessions" = "Sessões de nós remotos"; + +/* Import preview info text */ +"settings.backup.import.preview.info" = "Apenas os dados novos são adicionados; os registos existentes não são substituídos. Os contactos eliminados depois de este backup ter sido criado voltam a aparecer, e quaisquer estados de bloqueio, silenciado ou favorito do backup são reaplicados."; + +/* Import preview import button */ +"settings.backup.import.preview.button" = "Importar dados"; + +/* Import progress label */ +"settings.backup.import.progress" = "A importar dados…"; +/* Import parsing label */ +"settings.backup.import.parsing" = "A ler o backup…"; +/* Import cancelling label shown after the user taps Cancel during an active import */ +"settings.backup.import.cancelling" = "A cancelar…"; +/* Import preview cancel / dismiss toolbar button */ +"settings.backup.import.preview.cancel" = "Cancelar"; + +/* Import success title */ +"settings.backup.import.success.title" = "Importação concluída"; + +/* Import success section header: items that were newly added to this device */ +"settings.backup.import.success.added_section" = "Adicionado a este dispositivo"; + +/* Import success section header: items in the backup that were already on this device */ +"settings.backup.import.success.already_here_section" = "Já neste dispositivo"; + +/* Import success section footer: always visible under the "Already on this device" section */ +"settings.backup.import.success.already_here_footer" = "Os dados existentes do dispositivo foram mantidos tal como estavam; nada foi substituído."; + +/* Import success hero subtitle when new items were added (pluralized in .stringsdict) */ +"settings.backup.import.success.subtitle_added" = "%d itens adicionados."; + +/* Import success hero subtitle when only existing items were refreshed (pluralized in .stringsdict) */ +"settings.backup.import.success.subtitle_refreshed" = "%d itens existentes atualizados."; + +/* Import success disclosure row summary for the "Already on this device" section (pluralized in .stringsdict) */ +"settings.backup.import.success.already_here_summary" = "%d itens já presentes"; + +/* Import success footer line appended when some of the already-here items had metadata refreshed (pluralized in .stringsdict) */ +"settings.backup.import.success.already_here_refreshed" = "%d destes foram atualizados com informações mais recentes do backup."; + +/* Import success section header: channels that had no free local slot and couldn't be restored */ +"settings.backup.import.success.dropped_section" = "Não foi possível restaurar"; + +/* Import success disclosure row summary for the "Couldn't be restored" section */ +"settings.backup.import.success.dropped_summary" = "Não foi possível restaurar %d itens"; + +/* Import success footer explaining why some channels couldn't be restored and how to recover them */ +"settings.backup.import.success.dropped_footer" = "Estes canais não tinham slot livre neste rádio, por isso eles e as respetivas mensagens não foram importados. Liberte um slot de canal e importe novamente para os restaurar."; + +/* Import success footer when only discovered nodes exceeded the per-radio discover-list cap */ +"settings.backup.import.success.dropped_footer_discovered_nodes" = "Estes nós descobertos não puderam ser restaurados porque a lista de descoberta deste rádio está cheia (%d nós). Os nós locais existentes foram mantidos; a mesh voltará a emitir Adverts dos nós que faltam."; + +/* Import success footer when both channel slots and the discover-list cap caused drops */ +"settings.backup.import.success.dropped_footer_mixed" = "Alguns itens não puderam ser restaurados: canais sem slot livre no rádio e nós descobertos que excederam a capacidade da lista de descoberta deste rádio (%d nós). Liberte um slot de canal e importe novamente os canais; a mesh voltará a emitir Adverts dos nós descobertos em falta."; + +/* Import success done button */ +"settings.backup.import.success.done" = "Concluído"; + +/* Import failure title */ +"settings.backup.import.error.title" = "Falha na importação"; + +/* Import error dismiss button */ +"settings.backup.import.error.dismiss" = "Fechar"; + +/* Import cancelled title shown after user cancels an import before it commits */ +"settings.backup.import.cancelled.title" = "Importação cancelada"; + +/* Import cancelled message — rollback ran and nothing on device was changed */ +"settings.backup.import.cancelled.message" = "Nenhum dado foi alterado."; + +/* File access error */ +/* Import preview manifest label: blocked senders */ +"settings.backup.import.preview.blocked_senders" = "Remetentes bloqueados"; + +/* Import preview manifest label: node status snapshots */ +"settings.backup.import.preview.node_status_snapshots" = "Instantâneos de nós"; + +/* Import preview manifest label: discovered nodes */ +"settings.backup.import.preview.discovered_nodes" = "Nós descobertos"; + +/* Import preview manifest label: message repeats */ +"settings.backup.import.preview.message_repeats" = "Retransmissões de mensagens"; + +/* Import header title when the backup had nothing new to add (every record was skipped) */ +"settings.backup.import.nothing_to_import.title" = "Já está tudo aqui"; + +/* Import subtitle when the backup had nothing new to add */ +"settings.backup.import.nothing_to_import.subtitle" = "O conteúdo deste backup já está neste dispositivo."; + +/* Default filename for an exported backup; %@ is an ISO-style timestamp */ +"settings.backup.export.default_filename" = "Backup MC1 %@.mc1backup"; + +/* Backup error: file is corrupt or unreadable */ +"settings.backup.error.invalid_file" = "O ficheiro de backup é inválido ou não pôde ser lido."; + +/* Backup error: file exceeds parser size cap; %1$d actual MB, %2$d max MB */ +"settings.backup.error.file_too_large" = "O ficheiro de backup é demasiado grande para importar (%1$d MB; o limite é %2$d MB)."; + +/* Backup error: decompressed size exceeds safety cap; %d is max MB uncompressed */ +"settings.backup.error.decompressed_too_large" = "O ficheiro de backup ultrapassa o limite seguro de tamanho depois de descomprimido (%d MB sem compressão)."; + +/* Backup error: file version too new; %1$d found, %2$d max supported */ +"settings.backup.error.unsupported_version" = "Este backup foi criado com um formato mais recente (versão %1$d). Esta app oferece suporte até à versão %2$d. Atualize a app e tente novamente."; + +/* Backup error: manifest counts do not match arrays */ +"settings.backup.error.corrupted_manifest" = "O ficheiro de backup parece estar corrompido. As quantidades declaradas não correspondem aos dados reais."; + +/* Backup error: export failed; %@ is underlying error */ +"settings.backup.error.export_failed" = "Não foi possível criar o backup: %@"; + +/* Backup error: import failed; %@ is underlying error */ +"settings.backup.error.import_failed" = "Não foi possível importar o backup: %@"; + +// MARK: - Export success + +/* Hero title on the backup export success sheet */ +"settings.backup.export.success.title" = "Backup guardado"; + +/* Section header listing record counts included in the export */ +"settings.backup.export.success.included_section" = "Incluído no backup"; + +/* Primary button on the export success sheet */ +"settings.backup.export.success.done" = "Concluído"; + +/* VoiceOver announcement when the export success sheet appears; %@ is the filename */ +"settings.backup.export.success.announcement" = "Backup guardado como %@"; + +/* Error shown when applying a theme the user does not own */ +"Support.Error.ThemeNotOwned" = "Não possui este tema."; + +/* VoiceOver announcement when a refund reverts the active theme to the default */ +"Appearance.Accessibility.ThemeReverted" = "Tema reposto para o predefinido."; + +/* MARK: Support (purchase) screen */ +"Support.Title" = "Apoiar o desenvolvimento"; +"Support.Header.Body" = "Se gosta do MC1, eis algumas formas de apoiar o desenvolvimento. Tudo aqui é opcional: alguns donativos e alguns temas extra."; +"Support.Themes.Title" = "Temas"; +"Support.Themes.Owned" = "Adquirido"; +"Support.Themes.PurchasedFooter" = "Aplique o novo tema em Definições → Aspeto."; +"Support.Themes.AllUnlocked" = "Todos os temas desbloqueados!"; +"Support.Bundle.Title" = "Todos os temas"; +"Support.Bundle.Subtitle" = "Inclui quaisquer temas adicionados mais tarde."; +"Support.Contributions.Title" = "Donativos"; +"Support.Contributions.Footer" = "Os donativos são um agradecimento e apoiam o desenvolvimento do MC1. Não incluem bens, serviços nem benefícios de associação."; +"Support.Contributions.HighValueConfirm" = "Enviar %@?"; +"Support.Contributions.ConfirmButton" = "Contribuir %@"; +"Support.Restore.Button" = "Restaurar compras"; +"Support.Restore.Syncing" = "A restaurar…"; +"Support.Refund.Link" = "Pedir um reembolso"; +"Support.Pending.Banner" = "A aguardar aprovação. A compra é desbloqueada assim que for aprovada."; +"Support.Pending.Button" = "A aguardar aprovação"; +"Support.Contact.Link" = "Enviar e-mail ao programador"; +"Support.ThankYou.Title" = "Obrigado!"; +"Support.ThankYou.Body" = "Obrigado pelo apoio, significa muito. Vou continuar a trabalhar para melhorar o MC1!"; + +/* MARK: Feedback screen */ +"Feedback.Title" = "Feedback"; +"Feedback.Header.Body" = "GitHub Issues é o método preferido, mas o e-mail também serve."; +"Feedback.GitHub.Link" = "Abrir um GitHub Issue"; +"Feedback.Email.Link" = "Enviar e-mail ao programador"; + +/* MARK: Theme display names */ +"Support.Theme.Default" = "Predefinido"; +"Support.Theme.Ember" = "Ember"; +"Support.Theme.Fern" = "Fern"; +"Support.Theme.Marine" = "Marine"; +"Support.Theme.Olive" = "Olive"; +"Support.Theme.Lavender" = "Lavender"; + +/* MARK: Support error messages (mapped from StoreServiceError) */ +"Support.Error.ProductsNotLoaded" = "Não foi possível carregar os produtos."; +"Support.Error.ProductNotFound" = "Produto não encontrado."; +"Support.Error.PurchaseFailed" = "A compra falhou: %@"; +"Support.Error.NetworkUnavailable" = "Rede indisponível."; +"Support.Error.VerificationFailed" = "Não foi possível verificar a compra."; +"Support.Error.NotEntitled" = "As compras estão restringidas neste dispositivo. Verifique as restrições de conteúdo em Tempo de Ecrã."; +"Support.Error.StorefrontUnavailable" = "Este produto não está disponível na sua região."; +"Support.Error.Unsupported" = "As compras na app não são suportadas neste dispositivo."; + +/* MARK: Support accessibility (label = identity+state, hint = action) */ +"Support.Accessibility.ThemeCard.LockedLabel" = "Tema %@, bloqueado"; +"Support.Accessibility.ThemeCard.OwnedLabel" = "Tema %@, adquirido"; +"Support.Accessibility.BundleCard.LockedLabel" = "Pacote Todos os temas, bloqueado, %@"; +"Support.Accessibility.BundleCard.LockedHint" = "Compre o pacote Todos os temas"; +"Support.Accessibility.BundleCard.LoadingLabel" = "Pacote Todos os temas, a carregar o preço"; +"Support.Accessibility.ContributionRow.Label" = "%1$@, %2$@"; +"Support.Accessibility.ContributionRow.Hint" = "Envie um donativo de agradecimento"; +"Support.Accessibility.TipConfirmAnnouncement" = "Obrigado pelo donativo."; + +/* MARK: Appearance (selection) screen */ +"Appearance.Title" = "Aspeto"; +"Appearance.Scheme.Header" = "Modo claro/escuro"; +"Appearance.Scheme.System" = "Sistema"; +"Appearance.Scheme.Light" = "Claro"; +"Appearance.Scheme.Dark" = "Escuro"; +"Appearance.Themes.Header" = "Escolha um tema"; +"Appearance.Themes.Selected" = "Selecionado"; +"Appearance.MoreThemes.Link" = "Comprar mais temas"; + +/* MARK: Appearance accessibility */ +"Appearance.Accessibility.ThemeCard.OwnedLabel" = "Tema %@, adquirido"; +"Appearance.Accessibility.ThemeCard.OwnedHint" = "Aplica este tema"; +"Appearance.Accessibility.ThemeCard.SelectedLabel" = "Tema %@, selecionado neste momento"; +"Appearance.Accessibility.Swatch.DualMode" = "%@, variantes clara e escura"; +"Appearance.Accessibility.Swatch.DarkOnly" = "%@, apenas escuro"; diff --git a/MC1/Resources/Localization/pt.lproj/Settings.stringsdict b/MC1/Resources/Localization/pt.lproj/Settings.stringsdict new file mode 100644 index 000000000..ac447a569 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Settings.stringsdict @@ -0,0 +1,126 @@ + + + + + + dangerZone.alert.removeUnfavorited.message + + NSStringLocalizedFormatKey + %#@nodeCount@ + nodeCount + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Isto irá eliminar permanentemente do dispositivo e da app %d nó não marcado como favorito, juntamente com as respetivas mensagens. + other + Isto irá eliminar permanentemente do dispositivo e da app %d nós não marcados como favoritos, juntamente com as respetivas mensagens. + + + dangerZone.alert.removeUnfavorited.partial + + NSStringLocalizedFormatKey + %2$#@totalNodes@ + totalNodes + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Removido %1$d de %2$d nó. A ligação foi interrompida. Toque novamente no botão para tentar de novo. + other + Removidos %1$d de %2$d nós. A ligação foi interrompida. Toque novamente no botão para tentar remover os nós restantes. + + + settings.backup.import.success.subtitle_added + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item adicionado. + other + %d itens adicionados. + + + settings.backup.import.success.subtitle_refreshed + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item existente atualizado. + other + %d itens existentes atualizados. + + + settings.backup.import.success.already_here_summary + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item já presente + other + %d itens já presentes + + + settings.backup.import.success.already_here_refreshed + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d destes foi atualizado com informações mais recentes do backup. + other + %d destes foram atualizados com informações mais recentes do backup. + + + settings.backup.import.success.dropped_summary + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Não foi possível restaurar %d item + other + Não foi possível restaurar %d itens + + + + diff --git a/MC1/Resources/Localization/pt.lproj/Tools.strings b/MC1/Resources/Localization/pt.lproj/Tools.strings new file mode 100644 index 000000000..dccb5f040 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Tools.strings @@ -0,0 +1,866 @@ +/* + Tools.strings + MC1 + + European Portuguese (pt-PT) translation of tools and App Intents strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Tools View + +/* Location: ToolsView.swift - Navigation title */ +"tools.title" = "Ferramentas"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.tracePath" = "Traçar rota"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.lineOfSight" = "Linha de vista"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.rxLog" = "Log RX"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.noiseFloor" = "Ruído de fundo"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.nodeDiscovery" = "Descobrir nós"; + +/* Location: ToolsView.swift - Empty state when no tool selected */ +"tools.selectTool" = "Selecione uma ferramenta"; + +// MARK: - RX Log View + +/* Location: RxLogView.swift - Empty state title when listening */ +"tools.rxLog.listening" = "A escutar..."; + +/* Location: RxLogView.swift - Empty state description */ +"tools.rxLog.listeningDescription" = "Os pacotes RF aparecem aqui à medida que chegam."; + +/* Location: RxLogView.swift - Disconnected state title */ +"tools.rxLog.notConnected" = "Sem ligação"; + +/* Location: RxLogView.swift - Disconnected state description */ +"tools.rxLog.notConnectedDescription" = "Ligue o rádio mesh para ver os pacotes RF."; + +/* Location: RxLogView.swift - Live status indicator */ +"tools.rxLog.live" = "Em direto"; + +/* Location: RxLogView.swift - Offline status indicator */ +"tools.rxLog.offline" = "Offline"; + +/* Location: RxLogView.swift - Packet count in header, %lld is count */ +"tools.rxLog.packetsCount" = "%lld pacotes"; + +/* Location: RxLogView.swift - Filter menu section header */ +"tools.rxLog.routeType" = "Tipo de rota"; + +/* Location: RxLogView.swift - Filter menu section header */ +"tools.rxLog.decryptStatus" = "Estado da desencriptação"; + +/* Location: RxLogView.swift - Filter button label */ +"tools.rxLog.filter" = "Filtrar"; + +/* Location: RxLogView.swift - Overflow menu button label */ +"tools.rxLog.more" = "Mais"; + +/* Location: RxLogView.swift - Group duplicates toggle */ +"tools.rxLog.groupDuplicates" = "Agrupar duplicados"; + +/* Location: RxLogView.swift - Delete logs button */ +"tools.rxLog.deleteLogs" = "Eliminar registos"; + +/* Location: RxLogView.swift - Delete confirmation dialog title */ +"tools.rxLog.deleteConfirmation" = "Eliminar todos os registos?"; + +/* Location: RxLogView.swift - Delete confirmation button */ +"tools.rxLog.delete" = "Eliminar"; + +/* Location: RxLogView.swift - Direct route label */ +"tools.rxLog.direct" = "Direto"; + +/* Location: RxLogView.swift - Path detail for single hop */ +"tools.rxLog.hopSingular" = "salto"; + +/* Location: RxLogView.swift - Path detail for multiple hops */ +"tools.rxLog.hopPlural" = "saltos"; + +/* Location: RxLogView.swift - Signal strength accessibility label, %@ is quality */ +"tools.rxLog.signalStrength" = "Sinal: %@"; + +/* Location: RxLogView.swift - Duplicate count accessibility label, %lld is count */ +"tools.rxLog.receivedTimes" = "Recebido %lld vezes"; + +/* Location: RxLogView.swift - Raw payload section title */ +"tools.rxLog.rawPayload" = "Payload em bruto"; + +/* Location: RxLogView.swift - Copy button */ +"tools.rxLog.copy" = "Copiar"; + +/* Location: RxLogView.swift - RSSI label */ +"tools.rxLog.rssiLabel" = "RSSI:"; + +/* Location: RxLogView.swift - SNR label */ +"tools.rxLog.snrLabel" = "SNR:"; + +/* Location: RxLogView.swift - Type label */ +"tools.rxLog.typeLabel" = "Tipo:"; + +/* Location: RxLogView.swift - Size label */ +"tools.rxLog.sizeLabel" = "Tamanho:"; + +/* Location: RxLogView.swift - Path label */ +"tools.rxLog.pathLabel" = "Rota:"; + +/* Location: RxLogView.swift - Hash label */ +"tools.rxLog.hashLabel" = "Hash:"; + +/* Location: RxLogView.swift - From label */ +"tools.rxLog.fromLabel" = "De:"; + +/* Location: RxLogView.swift - To label */ +"tools.rxLog.toLabel" = "Para:"; + +/* Location: RxLogView.swift - Channel hash label */ +"tools.rxLog.channelHashLabel" = "Hash do canal:"; + +/* Location: RxLogView.swift - Channel name label */ +"tools.rxLog.channelNameLabel" = "Nome do canal:"; + +/* Location: RxLogView.swift - Region label */ +"tools.rxLog.regionLabel" = "Região:"; + +/* Location: RxLogView.swift - Unresolved region value */ +"tools.rxLog.regionUnresolved" = "Desconhecida"; + +/* Location: RxLogView.swift - Text label */ +"tools.rxLog.textLabel" = "Texto:"; + +/* Location: RxLogView.swift - Bytes suffix for size display */ +"tools.rxLog.bytes" = "bytes"; + +// MARK: - RX Log Filters + +/* Location: RxLogViewModel.swift - Route filter: all */ +"tools.rxLog.filter.all" = "Todos"; + +/* Location: RxLogViewModel.swift - Route filter: flood only */ +"tools.rxLog.filter.floodOnly" = "Só difusão"; + +/* Location: RxLogViewModel.swift - Route filter: direct only */ +"tools.rxLog.filter.directOnly" = "Só direto"; + +/* Location: RxLogViewModel.swift - Decrypt filter: decrypted */ +"tools.rxLog.filter.decrypted" = "Desencriptado"; + +/* Location: RxLogViewModel.swift - Decrypt filter: failed */ +"tools.rxLog.filter.failed" = "Falhou"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: not a decryptable packet */ +"tools.rxLog.decryptStatus.notApplicable" = "N/D"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: no stored channel key matches */ +"tools.rxLog.decryptStatus.noKey" = "Sem chave"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: HMAC validation failed */ +"tools.rxLog.decryptStatus.hmacFailed" = "HMAC falhou"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: AES decryption failed */ +"tools.rxLog.decryptStatus.decryptFailed" = "Falha na desencriptação"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: decrypted successfully */ +"tools.rxLog.decryptStatus.decrypted" = "Desencriptado"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: key found, decryption pending */ +"tools.rxLog.decryptStatus.hasKey" = "Com chave"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: missing direct message key */ +"tools.rxLog.decryptStatus.noDmKey" = "Sem chave de contacto"; + +/* Location: RxLogRowView - Label for local device in path display */ +"tools.rxLog.pathYou" = "Eu"; + +/* Location: RxLogRowView - Route label for TRACE target hashes */ +"tools.rxLog.traceRouteLabel" = "Rota:"; + +// MARK: - Noise Floor View + +/* Location: NoiseFloorView.swift - Disconnected state description */ +"tools.noiseFloor.notConnectedDescription" = "Ligue o rádio mesh para medir o ruído de fundo."; + +/* Location: NoiseFloorView.swift - Collecting data title */ +"tools.noiseFloor.collectingData" = "A recolher dados..."; + +/* Location: NoiseFloorView.swift - Collecting data description */ +"tools.noiseFloor.collectingDataDescription" = "As leituras de ruído de fundo aparecem à medida que são recolhidas."; + +/* Location: NoiseFloorView.swift - Unit label */ +"tools.noiseFloor.dBm" = "dBm"; + +/* Location: NoiseFloorView.swift - Unit label for decibels */ +"tools.noiseFloor.dB" = "dB"; + +/* Location: NoiseFloorView.swift - No reading accessibility */ +"tools.noiseFloor.noReading" = "Nenhuma leitura disponível"; + +/* Location: NoiseFloorView.swift - Chart accessibility, %lld readings, %lld min, %lld max, %lld avg, %@ trend */ +"tools.noiseFloor.chartAccessibility" = "Histórico de ruído de fundo: %lld leituras, mínimo %lld dBm, máximo %lld dBm, média %lld dBm, tendência %@"; + +/* Location: NoiseFloorView.swift - Chart accessibility when empty */ +"tools.noiseFloor.chartAccessibilityEmpty" = "Gráfico do histórico de ruído de fundo, sem dados"; + +/* Location: NoiseFloorView.swift - Statistics section title */ +"tools.noiseFloor.statistics" = "Estatísticas"; + +/* Location: NoiseFloorView.swift - Minimum label */ +"tools.noiseFloor.minimum" = "Mínimo"; + +/* Location: NoiseFloorView.swift - Average label */ +"tools.noiseFloor.average" = "Média"; + +/* Location: NoiseFloorView.swift - Maximum label */ +"tools.noiseFloor.maximum" = "Máximo"; + +/* Location: NoiseFloorView.swift - Last RSSI label */ +"tools.noiseFloor.lastRssi" = "Último RSSI"; + +/* Location: NoiseFloorView.swift - Last SNR label */ +"tools.noiseFloor.lastSnr" = "Último SNR"; + +/* Location: NoiseFloorView.swift - Trend: stable */ +"tools.noiseFloor.trendStable" = "estável"; + +/* Location: NoiseFloorView.swift - Trend: increasing */ +"tools.noiseFloor.trendIncreasing" = "a aumentar"; + +/* Location: NoiseFloorView.swift - Trend: decreasing */ +"tools.noiseFloor.trendDecreasing" = "a diminuir"; + +// MARK: - Noise Floor Signal Quality + +/* Location: NoiseFloorViewModel.swift - Signal quality: excellent */ +"tools.noiseFloor.quality.excellent" = "Excelente"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: good */ +"tools.noiseFloor.quality.good" = "Bom"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: fair */ +"tools.noiseFloor.quality.fair" = "Razoável"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: poor */ +"tools.noiseFloor.quality.poor" = "Fraco"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: unknown */ +"tools.noiseFloor.quality.unknown" = "Desconhecido"; + +/* Location: NoiseFloorViewModel.swift - Error: device disconnected */ +"tools.noiseFloor.error.disconnected" = "Dispositivo desligado"; + +/* Location: NoiseFloorViewModel.swift - Error: unable to read stats */ +"tools.noiseFloor.error.unableToRead" = "Não foi possível ler as estatísticas do rádio"; + +// MARK: - Node Discovery View + +/* Location: NodeDiscoveryView.swift - Disconnected state description */ +"tools.nodeDiscovery.notConnectedDescription" = "Ligue o rádio mesh para descobrir %@."; + +/* Location: NodeDiscoveryView.swift - Initial empty state title */ +"tools.nodeDiscovery.scanPrompt" = "Procurar %@"; + +/* Location: NodeDiscoveryView.swift - Initial empty state description */ +"tools.nodeDiscovery.scanPromptDescription" = "Toque no botão abaixo para descobrir %@ nas proximidades."; + +/* Location: NodeDiscoveryView.swift - No results empty state title */ +"tools.nodeDiscovery.noResults" = "Sem resultados para %@"; + +/* Location: NodeDiscoveryView.swift - No results empty state description */ +"tools.nodeDiscovery.noResultsDescription" = "Nenhum dos %@ respondeu ao ping de descoberta. Tente novamente ou aproxime-se de um nó da mesh."; + +/* Location: NodeDiscoveryView.swift - Scan button label (idle) */ +"tools.nodeDiscovery.scanButton" = "Procurar %@"; + +/* Location: NodeDiscoveryView.swift - Scan button label (scanning) */ +"tools.nodeDiscovery.stopButton" = "Parar pesquisa"; + +/* Location: NodeDiscoveryView.swift - Segment label */ +"tools.nodeDiscovery.repeaters" = "Repetidores"; + +/* Location: NodeDiscoveryView.swift - Segment label */ +"tools.nodeDiscovery.sensors" = "Sensores"; + +/* Location: NodeDiscoveryView.swift - Sort option */ +"tools.nodeDiscovery.sortSignal" = "Intensidade do sinal"; + +/* Location: NodeDiscoveryView.swift - Sort option */ +"tools.nodeDiscovery.sortName" = "Nome"; + +/* Location: NodeDiscoveryView.swift - Sort menu accessibility label */ +"tools.nodeDiscovery.sortMenu" = "Ordenar nós"; + +/* Location: NodeDiscoveryView.swift - Sort menu accessibility hint */ +"tools.nodeDiscovery.sortMenuHint" = "Altera a ordem dos nós descobertos"; + +/* Location: NodeDiscoveryRowView.swift - Unknown node name fallback */ +"tools.nodeDiscovery.unknownNode" = "Desconhecido"; + +/* Location: NodeDiscoveryRowView.swift - Node type: repeater */ +"tools.nodeDiscovery.typeRepeater" = "Repetidor"; + +/* Location: NodeDiscoveryRowView.swift - Node type: sensor */ +"tools.nodeDiscovery.typeSensor" = "Sensor"; + +/* Location: NodeDiscoveryRowView.swift - SNR label (local receive) */ +"tools.nodeDiscovery.snrDown" = "↓ %@ dB"; + +/* Location: NodeDiscoveryRowView.swift - SNR label (remote receive) */ +"tools.nodeDiscovery.snrUp" = "↑ %@ dB"; + +/* Location: NodeDiscoveryRowView.swift - RSSI label */ +"tools.nodeDiscovery.rssi" = "RSSI %@ dBm"; + +/* Location: NodeDiscoveryView.swift - Error alert title */ +"tools.nodeDiscovery.errorTitle" = "A pesquisa falhou"; + +// MARK: - Line of Sight View + +/* Location: LineOfSightView.swift - Map style: standard */ +/* Location: LineOfSightView.swift - Map style: terrain */ +/* Location: LineOfSightView.swift - Back button label */ +"tools.lineOfSight.back" = "Voltar"; + +/* Location: LineOfSightView.swift - Point A annotation */ +"tools.lineOfSight.pointA" = "Ponto A"; + +/* Location: LineOfSightView.swift - Point B annotation */ +"tools.lineOfSight.pointB" = "Ponto B"; + +/* Location: LineOfSightView.swift - Repeater annotation */ +"tools.lineOfSight.repeater" = "Repetidor"; + +/* Location: LineOfSightView.swift - Points section title */ +"tools.lineOfSight.points" = "Pontos"; + +/* Location: LineOfSightView.swift - Cancel button */ +"tools.lineOfSight.cancel" = "Cancelar"; + +/* Location: LineOfSightView.swift - Relocating message, %@ is point name */ +"tools.lineOfSight.relocating" = "A reposicionar %@..."; + +/* Location: LineOfSightView.swift - Tap map instruction */ +"tools.lineOfSight.tapMapInstruction" = "Toque no mapa para definir um novo local"; + +/* Location: LineOfSightView.swift - Not selected placeholder */ +"tools.lineOfSight.notSelected" = "Não selecionado"; + +/* Location: LineOfSightView.swift - Loading elevation status */ +"tools.lineOfSight.loadingElevation" = "A carregar altitude..."; + +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Toque sem soltar no mapa para adicionar um local"; + +/* Location: LineOfSightView.swift - Elevation unavailable warning */ +"tools.lineOfSight.elevationUnavailable" = "Dados de altitude indisponíveis. A utilizar o nível do mar (0 m) como aproximação."; + +/* Location: LineOfSightView.swift - Open in Maps button */ +"tools.lineOfSight.openInMaps" = "Abrir em Mapas"; + +/* Location: LineOfSightView.swift - Copy coordinates button */ +"tools.lineOfSight.copyCoordinates" = "Copiar coordenadas"; + +/* Location: LineOfSightView.swift - Share button */ +"tools.lineOfSight.share" = "Partilhar..."; + +/* Location: LineOfSightView.swift - Share label */ +"tools.lineOfSight.shareLabel" = "Partilhar"; + +/* Location: LineOfSightView.swift - Relocate button */ +"tools.lineOfSight.relocate" = "Reposicionar"; + +/* Location: LineOfSightView.swift - Done button */ +"tools.lineOfSight.done" = "Concluído"; + +/* Location: LineOfSightView.swift - Edit button */ +"tools.lineOfSight.edit" = "Editar"; + +/* Location: LineOfSightView.swift - Clear button */ +"tools.lineOfSight.clear" = "Limpar"; + +/* Location: LineOfSightView.swift - Ground elevation label */ +"tools.lineOfSight.groundElevation" = "Altitude do terreno"; + +/* Location: LineOfSightView.swift - Additional height label */ +"tools.lineOfSight.additionalHeight" = "Altura adicional"; + +/* Location: LineOfSightView.swift - Total height label */ +"tools.lineOfSight.totalHeight" = "Altura total"; + +/* Location: LineOfSightView.swift - Add repeater button */ +"tools.lineOfSight.addRepeater" = "Adicionar repetidor"; + +/* Location: LineOfSightView.swift - Analyze button */ +"tools.lineOfSight.analyze" = "Analisar linha de vista"; + +/* Location: LineOfSightView.swift - Terrain profile section */ +"tools.lineOfSight.terrainProfile" = "Perfil do terreno"; + +/* Location: LineOfSightView.swift - Earth curvature note, %@ is k-factor */ +"tools.lineOfSight.earthCurvature" = "Corrigido para a curvatura da Terra (%@)"; + +/* Location: LineOfSightView.swift - Drag to adjust tooltip */ +"tools.lineOfSight.dragToAdjust" = "Arraste para ajustar"; + +/* Location: LineOfSightView.swift - RF Settings section */ +"tools.lineOfSight.rfSettings" = "Definições RF"; + +/* Location: LineOfSightView.swift - Frequency label */ +"tools.lineOfSight.frequency" = "Frequência"; + +/* Location: LineOfSightView.swift - MHz unit */ +"tools.lineOfSight.mhz" = "MHz"; + +/* Location: LineOfSightView.swift - Refraction label */ +"tools.lineOfSight.refraction" = "Refração"; + +/* Location: LineOfSightView.swift - Refraction: none */ +"tools.lineOfSight.refraction.none" = "Nenhuma"; + +/* Location: LineOfSightView.swift - Refraction: standard */ +"tools.lineOfSight.refraction.standard" = "Padrão (k=1,33)"; + +/* Location: LineOfSightView.swift - Refraction: ducting */ +"tools.lineOfSight.refraction.ducting" = "Conduta (k=4)"; + +/* Location: LineOfSightView.swift - Analyzing progress */ +"tools.lineOfSight.analyzing" = "A analisar o percurso..."; + +/* Location: LineOfSightView.swift - Analysis failed title */ +"tools.lineOfSight.analysisFailed" = "A análise falhou"; + +/* Location: LineOfSightView.swift - Retry button */ +"tools.lineOfSight.retry" = "Tentar novamente"; + +/* Location: LineOfSightView.swift - Repeater location map item name */ +"tools.lineOfSight.repeaterLocation" = "Localização do repetidor"; + +/* Location: LineOfSightViewModel.swift - Dropped pin display name */ +"tools.lineOfSight.droppedPin" = "Ponto no mapa"; + +/* Location: LOSRepeaterPinView.swift - Accessibility hint for repeater pins */ +/* Location: LOSPointPinView.swift - Accessibility hint for point pins */ +/* Location: LOSRepeaterTargetPinView.swift - Accessibility hint for repeater target */ +// MARK: - Terrain Profile Canvas + +/* Location: TerrainProfileCanvas.swift - Empty state title */ +"tools.lineOfSight.noData" = "Sem dados"; + +/* Location: TerrainProfileCanvas.swift - Empty state description */ +"tools.lineOfSight.selectTwoPoints" = "Selecione dois pontos para analisar"; + +/* Location: TerrainProfileCanvas.swift - Legend: terrain */ +"tools.lineOfSight.legend.terrain" = "Terreno"; + +/* Location: TerrainProfileCanvas.swift - Legend: line of sight */ +"tools.lineOfSight.legend.los" = "LOS"; + +/* Location: TerrainProfileCanvas.swift - Legend: clear */ +"tools.lineOfSight.legend.clear" = "Livre"; + +/* Location: TerrainProfileCanvas.swift - Legend: obstructed */ +"tools.lineOfSight.legend.obstructed" = "Obstruído"; + +/* Location: TerrainProfileCanvas.swift - Indirect route label */ +"tools.lineOfSight.indirectRoute" = "Rota indireta via R · Reposicione no mapa para ajustar"; + +/* Location: TerrainProfileCanvas.swift - Elevation data attribution */ +"tools.lineOfSight.elevationAttribution" = "Dados de altitude: Copernicus DEM GLO-90 via Open-Meteo"; + +// MARK: - Results Card View + +/* Location: ResultsCardView.swift - Section title */ +"tools.lineOfSight.results" = "Resultados"; + +/* Location: ResultsCardView.swift - Loss suffix */ +"tools.lineOfSight.loss" = "perda"; + +/* Location: ResultsCardView.swift - Path loss breakdown section */ +"tools.lineOfSight.pathLossBreakdown" = "Detalhe da perda de percurso"; + +/* Location: ResultsCardView.swift - Free space loss label */ +"tools.lineOfSight.freeSpaceLoss" = "Perda em espaço livre"; + +/* Location: ResultsCardView.swift - Diffraction loss label */ +"tools.lineOfSight.diffractionLoss" = "Perda por difração"; + +/* Location: ResultsCardView.swift - Total label */ +"tools.lineOfSight.total" = "Total"; + +/* Location: ResultsCardView.swift - Clearance section title */ +"tools.lineOfSight.clearance" = "Folga"; + +/* Location: ResultsCardView.swift - Obstructions found label */ +"tools.lineOfSight.obstructionsFound" = "Obstruções encontradas"; + +/* Location: ResultsCardView.swift - Status label */ +"tools.lineOfSight.status" = "Estado"; + +/* Location: ResultsCardView.swift - Distance label */ +"tools.lineOfSight.distance" = "Distância"; + +/* Location: ResultsCardView.swift - Worst clearance short label */ +"tools.lineOfSight.worstClearanceShort" = "Pior folga"; + +/* Location: ResultsCardView.swift - Assumptions footnote; %1$@ is frequency, %2$@ is k-factor */ +"tools.lineOfSight.assumptions" = "%1$@, %2$@, limiar de 60%% da 1.ª zona de Fresnel"; + +/* Location: ClearanceStatusView.swift - Clearance percentage, %lld is percent */ +"tools.lineOfSight.clearancePercent" = "%lld%% de folga"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: clear */ +"tools.lineOfSight.status.clear" = "Livre"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: marginal */ +"tools.lineOfSight.status.marginal" = "No limite"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: partial obstruction */ +"tools.lineOfSight.status.partialObstruction" = "Obstrução parcial"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: blocked */ +"tools.lineOfSight.status.blocked" = "Bloqueado"; + +/* Location: ResultsCardView.swift - Subtitle shown when path is blocked */ +"tools.lineOfSight.status.blockedSubtitle" = "O percurso direto intersecta o terreno"; + +// MARK: - CLI Tool View + +/* Location: CLIToolView.swift - Tool selection label */ +"tools.cli" = "CLI"; + +/* Location: CLIToolView.swift - Disconnected state title */ +"tools.cli.notConnected" = "Sem ligação"; + +/* Location: CLIToolView.swift - Disconnected state description */ +"tools.cli.notConnectedDescription" = "Ligue o rádio mesh para utilizar a CLI."; + +/* Location: CLIToolView.swift - Prompt suffix */ +"tools.cli.promptSuffix" = ">"; + +/* Location: CLIToolView.swift - Waiting indicator */ +/* Location: CLIToolView.swift - Disconnected prompt */ +"tools.cli.disconnected" = "desligado"; + +/* Location: CLIToolView.swift - Help command output header */ +"tools.cli.helpHeader" = "Comandos disponíveis:"; + +/* Location: CLIToolView.swift - Session list header */ +"tools.cli.sessionListHeader" = "Sessões ativas:"; + +/* Location: CLIToolView.swift - Local session label */ +"tools.cli.sessionLocal" = "local"; + +/* Location: CLIToolView.swift - No sessions message */ +/* Location: CLIToolView.swift - History empty message */ +/* Location: CLIToolView.swift - Unknown command error */ +"tools.cli.unknownCommand" = "Comando desconhecido:"; + +/* Location: CLIToolView.swift - Login success */ +"tools.cli.loginSuccess" = "Sessão iniciada em"; + +/* Location: CLIToolView.swift - Login failed */ +"tools.cli.loginFailed" = "Falha ao iniciar sessão:"; + +/* Location: CLIToolView.swift - Login failed reason */ +"tools.cli.loginFailedAuth" = "Falha na autenticação"; + +/* Location: CLIToolView.swift - Node not found error */ +"tools.cli.nodeNotFound" = "Nó não encontrado:"; + +/* Location: CLIToolView.swift - Password required error */ +"tools.cli.passwordRequired" = "Palavra-passe necessária"; + +/* Location: CLIToolViewModel.swift - Password prompt */ +"tools.cli.passwordPrompt" = "Palavra-passe:"; + +/* Location: CLIToolViewModel.swift - Login countdown */ +"tools.cli.loggingIn" = "A iniciar sessão... (%ds)"; + +/* Location: CLIToolView.swift - Logout success */ +"tools.cli.logoutSuccess" = "Sessão terminada"; + +/* Location: CLIToolView.swift - Not logged in error */ +"tools.cli.notLoggedIn" = "Sem sessão iniciada em nenhum repetidor"; + +/* Location: CLIToolView.swift - Session switched */ +"tools.cli.sessionSwitched" = "Sessão alterada para"; + +/* Location: CLIToolView.swift - Session not found */ +"tools.cli.sessionNotFound" = "Sessão não encontrada:"; + +/* Location: CLIToolView.swift - Command timeout */ +"tools.cli.timeout" = "Tempo esgotado à espera da resposta"; + +/* Location: CLIToolView.swift - Command cancelled */ +"tools.cli.cancelled" = "Comando cancelado"; + +/* Location: CLIToolView.swift - Login from local only */ +"tools.cli.loginFromLocalOnly" = "O início de sessão só está disponível a partir da sessão local"; + +/* Location: CLIToolView.swift - Login usage */ +"tools.cli.loginUsage" = "Uso: login [-f] "; + +/* Location: CLIToolView.swift - Accessory button: history up */ +"tools.cli.historyUp" = "Comando anterior"; + +/* Location: CLIToolView.swift - Accessory button: history down */ +"tools.cli.historyDown" = "Comando seguinte"; + +/* Location: CLIToolView.swift - Accessory button: tab complete */ +"tools.cli.tabComplete" = "Completar com Tab"; + +/* Location: CLIInputAccessoryView.swift - Cursor left button */ +"tools.cli.cursorLeft" = "Mover o cursor para a esquerda"; + +/* Location: CLIInputAccessoryView.swift - Cursor right button */ +"tools.cli.cursorRight" = "Mover o cursor para a direita"; + +/* Location: CLIInputAccessoryView.swift - Paste button */ +"tools.cli.paste" = "Colar"; + +/* Location: CLIToolView.swift - Accessory button: clear */ +/* Location: CLIToolView.swift - Accessory button: sessions */ +"tools.cli.sessions" = "Sessões"; + +/* Location: CLIToolView.swift - Accessory button: dismiss */ +"tools.cli.dismiss" = "Ocultar teclado"; + +/* Location: CLIToolView.swift - Default device name */ +"tools.cli.defaultDevice" = "Dispositivo"; + +/* Location: CLIToolViewModel.swift - Help: login command */ +"tools.cli.helpLogin" = " login [-f] \n Iniciar sessão no repetidor (-f: esquecer palavra-passe guardada)"; + +/* Location: CLIToolViewModel.swift - Help: logout command */ +"tools.cli.helpLogout" = " logout\n Terminar sessão remota"; + +/* Location: CLIToolViewModel.swift - Help: session list command */ +"tools.cli.helpSessionList" = " session list\n Mostrar sessões ativas"; + +/* Location: CLIToolViewModel.swift - Help: session local command */ +"tools.cli.helpSessionLocal" = " session local\n Mudar para a sessão local"; + +/* Location: CLIToolViewModel.swift - Help: session name command */ +"tools.cli.helpSessionName" = " session \n Mudar para a sessão"; + +/* Location: CLIToolViewModel.swift - Help: session shortcut */ +"tools.cli.helpSessionShortcut" = " s\n Mudar para a sessão n (ex.: s1, s2)"; + +/* Location: CLIToolViewModel.swift - Help: clear command */ +"tools.cli.helpClear" = " clear\n Limpar o terminal"; + +/* Location: CLIToolViewModel.swift - Help: help command */ +"tools.cli.helpHelp" = " help\n Mostrar esta ajuda"; + +/* Location: CLIToolViewModel.swift - Help: nodes command */ +"tools.cli.helpNodes" = " nodes\n Mostrar lista de repetidores e salas"; + +/* Location: CLIToolViewModel.swift - Help: channels command */ +"tools.cli.helpChannels" = " channels\n Mostrar lista de canais com números de slot do dispositivo"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands header */ +"tools.cli.helpRepeaterHeader" = "Comandos do repetidor (passthrough):"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 1 */ +"tools.cli.helpRepeaterList1" = " ver, clock, reboot, advert, neighbors"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 2 */ +"tools.cli.helpRepeaterList2" = " get/set , password "; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 3 */ +"tools.cli.helpRepeaterList3" = " log start/stop/erase"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 4 */ +"tools.cli.helpRepeaterList4" = " setperm, tempradio, neighbor.remove"; + +/* Location: CLIToolViewModel+Sessions.swift - Nodes header with count */ +"tools.cli.nodesHeader" = "Nós (%lld):"; + +/* Location: CLIToolViewModel+Sessions.swift - Channels header with count */ +"tools.cli.channelsHeader" = "Canais (%lld):"; + +/* Location: CLIToolViewModel+Sessions.swift - No nodes found */ +"tools.cli.noNodes" = "Nenhum nó encontrado"; + +/* Location: CLIToolViewModel+Sessions.swift - No channels found */ +"tools.cli.noChannels" = "Nenhum canal encontrado"; + +/* Location: CLIToolViewModel+Sessions.swift - Channel empty name placeholder */ +"tools.cli.channelEmpty" = "vazio"; + +/* Location: CLIToolViewModel.swift - Welcome banner line 1 */ +"tools.cli.welcomeLine1" = "MeshCore One CLI"; + +/* Location: CLIToolViewModel.swift - Welcome banner line 2 */ +"tools.cli.welcomeConnected" = "Ligado a %@"; + +/* Location: CLIToolViewModel.swift - Welcome banner line 3 */ +"tools.cli.welcomeHint" = "Introduza 'help' para ver os comandos disponíveis."; + +/* Location: CLIToolView.swift - Jump to bottom button */ +"tools.cli.jumpToBottom" = "Ir para o fim"; + +/* Location: CLIToolView.swift - Accessibility label for command input */ +"tools.cli.commandInput" = "Introdução de comando"; + +/* Location: CLIToolView.swift - Accessibility label for command prompt */ +"tools.cli.commandPrompt" = "Prompt de comando"; + +/* Location: CLIInputAccessoryView.swift - Cancel operation button label */ +"tools.cli.cancelOperation" = "Cancelar operação"; + +/* Location: CLIToolViewModel.swift - Command timeout (post-login) */ +"tools.cli.commandTimeout" = "O pedido expirou"; + +/* Location: CLIToolViewModel.swift - Reboot command confirmation */ +"tools.cli.rebootSent" = "Comando de reinício enviado"; + +/* Accessibility label for completion suggestions container */ +/* Accessibility value for completion suggestions - %lld is count, %@ is selected */ +/* Location: CLIToolViewModel.swift - Confirmation prompt for a dangerous local command; %@ is the command name */ +"tools.cli.confirmPrompt" = "confirmar %@? (yes/no):"; + +/* Location: CLIToolViewModel+LocalCommands.swift - Usage for the get command */ +"tools.cli.usageGet" = "Uso: get "; + +/* Location: CLIToolViewModel+LocalCommands.swift - Usage for the set command */ +"tools.cli.usageSet" = "Uso: set "; + +/* Location: CLIToolViewModel+LocalCommands.swift - Usage for set radio */ +"tools.cli.usageSetRadio" = "Uso: set radio ,,,"; + +/* Location: CLIToolViewModel+LocalCommands.swift - Invalid value error */ +"tools.cli.invalidValue" = "Valor inválido"; + +/* Location: CLIToolViewModel+LocalCommands.swift - Custom var key/value contains a reserved character */ +"tools.cli.invalidCustomVarToken" = "As chaves não podem conter ':' nem ','; os valores não podem conter ','"; + +/* Location: CLIToolViewModel.swift - Local radio commands help header */ +"tools.cli.helpLocalHeader" = "Comandos de rádio locais:"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 1 */ +"tools.cli.helpLocalList1" = " clock [sync], ver, board"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 2 */ +"tools.cli.helpLocalList2" = " advert (zero-hop), floodadv (flood), reboot"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 3 */ +"tools.cli.helpLocalList3" = " get , set "; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 4 */ +"tools.cli.helpLocalList4" = " keys: name, lat, lon, tx, radio, freq, multi.acks, path.hash.mode, public.key, bat"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 5 */ +"tools.cli.helpLocalList5" = " get custom (todas as variáveis personalizadas do firmware); chaves get/set desconhecidas são encaminhadas para as variáveis personalizadas"; + +// MARK: - Saved Paths + +/* Location: SavedPathsViewModel.swift - Error loading saved paths */ +"tools.savedPaths.loadFailed" = "Não foi possível carregar as rotas guardadas."; + +/* Location: SavedPathsViewModel.swift - Error renaming a saved path */ +"tools.savedPaths.renameFailed" = "Não foi possível mudar o nome da rota."; + +/* Location: SavedPathsViewModel.swift - Error deleting a saved path */ +"tools.savedPaths.deleteFailed" = "Não foi possível eliminar a rota."; + +// MARK: - App Intents + +/* Location: MessageTargetEntity.swift - App Intents entity type name for a send target (a contact DM or a channel broadcast) */ +"intent.entity.target" = "Destinatário"; + +/* Location: MessageTargetEntity.swift - App Intents recipient-picker subtitle marking a channel (broadcast) target */ +"intent.entity.channel" = "Canal"; + +/* Location: StatusQueryIntent.swift - App Intents: title of the radio status query intent */ +"intent.status.title" = "Verificar estado do rádio"; + +/* Location: MC1AppShortcutsProvider.swift - App Shortcuts: short title for the radio status shortcut */ +"intent.status.shortTitle" = "Estado do rádio"; + +/* Location: StatusQueryIntent.swift - App Intents: description of the radio status query intent */ +"intent.status.description" = "Indica o nome, o estado da ligação e o último nível de bateria conhecido do rádio ligado."; + +/* Location: StatusQueryIntent.swift - Spoken when the app is still launching and has no state to read yet */ +"intent.status.dialog.notReady" = "O MeshCore One ainda está a iniciar. Tente novamente daqui a pouco."; + +/* Location: StatusQueryIntent.swift - Spoken when connected; %1$@ is the radio name, %2$lld is the cached battery percent */ +"intent.status.dialog.connectedWithBattery" = "%1$@ está ligado. A bateria está a cerca de %2$lld%%, segundo a última leitura."; + +/* Location: StatusQueryIntent.swift - Spoken when connected but no battery reading is available; %@ is the radio name */ +"intent.status.dialog.connectedNoBattery" = "%@ está ligado. Nenhuma leitura de bateria disponível."; + +/* Location: StatusQueryIntent.swift - Spoken while the radio is connecting; %@ is the radio name */ +"intent.status.dialog.connecting" = "%@ está a estabelecer ligação."; + +/* Location: StatusQueryIntent.swift - Spoken when not connected and a last radio name is known; %@ is the radio name */ +"intent.status.dialog.disconnectedNamed" = "%@ não está ligado."; + +/* Location: StatusQueryIntent.swift - Spoken when not connected and no radio has been connected before */ +"intent.status.dialog.disconnectedUnknown" = "Nenhum rádio está ligado."; + +/* Location: StatusQueryIntent.swift - Generic radio name used when no device name is known */ +"intent.status.radioFallbackName" = "O seu rádio"; + +/* Location: SendMessageIntent.swift - App Intents: title of the send message intent */ +"intent.send.title" = "Enviar uma mensagem"; + +/* Location: MC1AppShortcutsProvider.swift - App Shortcuts: short title for the send message shortcut */ +"intent.send.shortTitle" = "Enviar mensagem"; + +/* Location: SendMessageIntent.swift - App Intents: description of the send message intent */ +"intent.send.description" = "Envia texto para um contacto ou um canal no rádio."; + +/* Location: SendMessageIntent.swift - App Intents: title of the recipient parameter (a contact or a channel) */ +"intent.send.param.target" = "Destinatário"; + +/* Location: SendMessageIntent.swift - App Intents: title of the message text parameter */ +"intent.send.param.message" = "Mensagem"; + +/* Location: SendMessageIntent.swift - Confirmation prompt shown before sending; %@ is the recipient name */ +"intent.send.confirm" = "Enviar esta mensagem para %@?"; + +/* Location: SendMessageIntent.swift - Spoken when the app must come to the foreground to send (still connecting or reconnecting) */ +"intent.send.foreground" = "Abra o MeshCore One para concluir o envio desta mensagem."; + +/* Location: SendMessageIntent.swift - App Intents parameter summary shown in the shortcut editor; ${message} and ${target} are framework parameter tokens */ +"Send ${message} to ${target}" = "Enviar ${message} para ${target}"; + +// MARK: - Send Advert Intent + +/* Location: SendAdvertIntent.swift - App Intents: title of the send advert intent */ +"intent.advert.title" = "Enviar um Advert"; + +/* Location: MC1AppShortcutsProvider.swift - App Shortcuts: short title for the send advert shortcut */ +"intent.advert.shortTitle" = "Enviar Advert"; + +/* Location: SendAdvertIntent.swift - App Intents: description of the send advert intent */ +"intent.advert.description" = "Emite um Advert deste rádio para que os nós da mesh próximos o possam descobrir."; + +/* Location: SendAdvertIntent.swift - App Intents: title of the advert reach parameter (zero-hop or flood) */ +"intent.advert.param.reach" = "Alcance"; + +/* Location: AdvertReach.swift - App Intents: type name for the advert reach enum */ +"intent.advert.reach.type" = "Alcance do Advert"; + +/* Location: AdvertReach.swift - App Intents: zero-hop reach case (direct neighbors only) */ +"intent.advert.reach.zeroHop" = "Zero-hop"; + +/* Location: AdvertReach.swift - App Intents: flood reach case (entire mesh) */ +"intent.advert.reach.flood" = "Difusão"; + +/* Location: SendAdvertIntent.swift - Spoken after a zero-hop advert is sent */ +"intent.advert.dialog.sentZeroHop" = "Advert enviado para os nós próximos."; + +/* Location: SendAdvertIntent.swift - Spoken after a flood advert is sent */ +"intent.advert.dialog.sentFlood" = "Advert enviado por toda a mesh."; + +/* Location: SendAdvertIntent.swift - App Intents parameter summary shown in the shortcut editor; ${reach} is the framework parameter token */ +"Send a ${reach} advert" = "Enviar um Advert ${reach}"; diff --git a/MC1/Resources/Localization/pt.lproj/WhatsNew.strings b/MC1/Resources/Localization/pt.lproj/WhatsNew.strings new file mode 100644 index 000000000..a512038ef --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/WhatsNew.strings @@ -0,0 +1,31 @@ +/* + WhatsNew.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the "What's New" sheet shown once after a major update. + AI-translated - please verify with native speakers. +*/ + +/* Title of the What's New sheet shown once after an app update */ +"whatsNew.title" = "Novidades"; + +/* Button to dismiss the What's New sheet */ +"whatsNew.continueButton" = "Continuar"; + +/* Link on the What's New sheet to the full GitHub release notes */ +"whatsNew.fullReleaseNotes" = "Ver as notas de versão completas"; + +/* What's New v1.3 - Improved chats feature, title */ +"whatsNew.fasterChats.title" = "Conversas melhoradas"; +/* What's New v1.3 - Improved chats feature, description */ +"whatsNew.fasterChats.description" = "Ao abrir uma conversa, o ecrã salta para as mensagens novas em vez de ir para o fim. A hora aparece em cada balão, e o histórico e as pré-visualizações de links carregam mais depressa."; + +/* What's New v1.3 - Contact photos feature, title */ +"whatsNew.contactPhotos.title" = "Fotos de contactos"; +/* What's New v1.3 - Contact photos feature, description */ +"whatsNew.contactPhotos.description" = "Defina um avatar em qualquer contacto para reconhecer pessoas mais facilmente em listas e conversas."; + +/* What's New v1.3 - Map filters feature, title */ +"whatsNew.mapFilters.title" = "Filtros do mapa"; +/* What's New v1.3 - Map filters feature, description */ +"whatsNew.mapFilters.description" = "Filtre os pontos por favoritos, nós descobertos e tipo de nó."; diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index 0ea7adf3c..2cc11d44a 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -321,6 +321,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Пока нет сообщений"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Не удалось отправить"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Уведомления отключены"; @@ -556,6 +559,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Копировать"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Перевести"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Показать оригинал"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Перевод..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Переведено: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Детали повторов"; @@ -606,6 +621,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Регион: Неизвестно"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Регион (неоднозначный): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Подробнее"; @@ -890,6 +909,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Регион %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Регион, неоднозначный: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Несколько совпадающих регионов"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Более одного региона в вашем списке соответствует транспортному коду этого пакета. Приложение не может определить, какой из них использовался."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Несколько совпадающих регионов"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Более одного региона в вашем списке соответствует транспортному коду этого пакета. Приложение не может определить, какой из них использовался.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Время отправки: %@"; @@ -1094,7 +1129,7 @@ "chats.channelInfo.region" = "Регион"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Все регионы"; +"chats.channelInfo.region.allRegions" = "Без области"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (по умолчанию)"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index c50059365..b4d9df580 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Этот файл нельзя использовать как фото профиля."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Переместить и масштабировать"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Выбрать"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Кадрирование фото профиля"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Смахните вверх или вниз, чтобы масштабировать. Используйте действия, чтобы переместить фото."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Переместить вверх"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Переместить вниз"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Переместить влево"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Переместить вправо"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Псевдоним"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Последнее объявление"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Последний приём"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Непрочитанные сообщения"; diff --git a/MC1/Resources/Localization/ru.lproj/Localizable.strings b/MC1/Resources/Localization/ru.lproj/Localizable.strings index c348ba459..11c8ab3ef 100644 --- a/MC1/Resources/Localization/ru.lproj/Localizable.strings +++ b/MC1/Resources/Localization/ru.lproj/Localizable.strings @@ -336,7 +336,7 @@ "error.wifi.connectionFailed" = "Не удалось подключиться: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Время ожидания подключения истекло. Проверьте IP-адрес и убедитесь, что устройство находится в той же сети."; +"error.wifi.connectionTimeout" = "Время ожидания подключения истекло. Проверьте имя хоста или IP-адрес и убедитесь, что устройство доступно."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Нет подключения к устройству."; @@ -347,8 +347,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Время ожидания отправки истекло."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Недопустимый IP-адрес."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Недопустимое имя хоста или IP-адрес."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Недопустимый номер порта."; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 690db214e..bb24c1c83 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Последний сигнал"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Последний приём"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Публичный ключ"; diff --git a/MC1/Resources/Localization/ru.lproj/Onboarding.strings b/MC1/Resources/Localization/ru.lproj/Onboarding.strings index 8c31f5eea..b6a490b23 100644 --- a/MC1/Resources/Localization/ru.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/ru.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Данные подключения"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-адрес"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Имя хоста или IP-адрес"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Очистить IP-адрес"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Очистить имя хоста или IP-адрес"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Порт"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Очистить порт"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Введите адрес локальной сети вашего устройства MeshCore. Порт по умолчанию: 5000."; +"wifiConnection.connectionDetails.footer" = "Введите имя хоста или IP-адрес устройства MeshCore. Порт по умолчанию — 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Подключение..."; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index de8fc28f0..5a5048693 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -346,15 +346,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Нажмите «Сохранить на ретранслятор», чтобы сохранить изменения после перезагрузки."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Весь трафик"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Без области"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Весь трафик)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Без области)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Домашний регион"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Область по умолчанию"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Нет"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Задаёт область пакетам, исходящим от этого узла, например объявлениям. Этот параметр сразу сохраняется на ретрансляторе."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -385,6 +394,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Не удалось добавить регион"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Названия регионов могут содержать только буквы, цифры и дефисы."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Названия регионов ограничены %d байтами."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Этот регион уже существует."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Не удалось удалить регион"; diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index affab7508..dacede36d 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -321,6 +321,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Поки що немає повідомлень"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Не вдалося надіслати"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Сповіщення вимкнено"; @@ -556,6 +559,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Копіювати"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Перекласти"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Показати оригінал"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Переклад..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Перекладено: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Деталі повторів"; @@ -606,6 +621,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Регіон: Невідомо"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Регіон (неоднозначний): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Докладніше"; @@ -890,6 +909,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Регіон %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Регіон, неоднозначний: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Кілька відповідних регіонів"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Кілька регіонів у вашому списку відповідають транспортному коду цього пакета. Застосунок не може визначити, який саме використано."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Кілька відповідних регіонів"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Кілька регіонів у вашому списку відповідають транспортному коду цього пакета. Застосунок не може визначити, який саме використано.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Час надсилання: %@"; @@ -1096,7 +1131,7 @@ "chats.channelInfo.region" = "Регіон"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Усі регіони"; +"chats.channelInfo.region.allRegions" = "Без області"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (за замовчуванням)"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index eb186b868..607bade73 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Цей файл не можна використати як фото профілю."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Перемістити та масштабувати"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Вибрати"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Кадрування фото профілю"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Проведіть вгору або вниз, щоб змінити масштаб. Використовуйте дії, щоб перемістити фото."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Перемістити вгору"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Перемістити вниз"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Перемістити ліворуч"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Перемістити праворуч"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Псевдонім"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Останнє оголошення"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Останнє отримання"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Непрочитані повідомлення"; diff --git a/MC1/Resources/Localization/uk.lproj/Localizable.strings b/MC1/Resources/Localization/uk.lproj/Localizable.strings index 1d638e3cf..047c29385 100644 --- a/MC1/Resources/Localization/uk.lproj/Localizable.strings +++ b/MC1/Resources/Localization/uk.lproj/Localizable.strings @@ -339,7 +339,7 @@ "error.wifi.connectionFailed" = "Не вдалося під'єднатися: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Час очікування з'єднання вичерпано. Перевірте IP-адресу та переконайтеся, що пристрій у тій самій мережі."; +"error.wifi.connectionTimeout" = "Час очікування з'єднання вичерпано. Перевірте ім'я хоста або IP-адресу та переконайтеся, що пристрій доступний."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Немає з'єднання з пристроєм."; @@ -350,8 +350,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Час очікування надсилання вичерпано."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Неприпустима IP-адреса."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Неприпустиме ім'я хоста або IP-адреса."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Неприпустимий номер порту."; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 29f46ff90..b34dcad2b 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Останнє оголошення"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Останнє отримання"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Публічний ключ"; diff --git a/MC1/Resources/Localization/uk.lproj/Onboarding.strings b/MC1/Resources/Localization/uk.lproj/Onboarding.strings index 39565bf9a..f14ed284e 100644 --- a/MC1/Resources/Localization/uk.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/uk.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Деталі підключення"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-адреса"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Ім'я хоста або IP-адреса"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Очистити IP-адресу"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Очистити ім'я хоста або IP-адресу"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Порт"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Очистити порт"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Введіть адресу локальної мережі вашого пристрою MeshCore. Порт за замовчуванням: 5000."; +"wifiConnection.connectionDetails.footer" = "Введіть ім'я хоста або IP-адресу пристрою MeshCore. Порт за замовчуванням: 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Підключення..."; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index a4daa9b96..9c82c77eb 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -346,15 +346,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Збережіть на ретранслятор, щоб зберегти зміни після перезавантажень."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Увесь трафік"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Без області"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Увесь трафік)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Без області)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Домашній регіон"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Область за замовчуванням"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Немає"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Застосовує область до пакетів, що походять від цього вузла, наприклад оголошень. Це налаштування одразу зберігається на ретрансляторі."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -385,6 +394,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Не вдалося додати регіон"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Назви регіонів можуть містити лише літери, цифри та дефіси."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Назви регіонів обмежені %d байтами."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Цей регіон уже існує."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Не вдалося видалити регіон"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index 83ee22b88..a0bb935c0 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "暂无消息"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "发送失败"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "已静音"; @@ -591,6 +594,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "复制"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "翻译"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "显示原文"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "翻译中..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "已翻译:%@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "转发详情"; @@ -641,6 +656,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "区域:未知"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "区域(有歧义):%@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "详情"; @@ -748,6 +766,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "区域 %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "区域,有歧义:%@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "多个匹配区域"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "您的列表中有多个区域与此数据包的传输代码匹配。应用无法确定使用了哪一个。"; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "多个匹配区域"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "您的列表中有多个区域与此数据包的传输代码匹配。应用无法确定使用了哪一个。%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "发送时间:%@"; @@ -1105,7 +1138,7 @@ "chats.channelInfo.region" = "区域"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "所有区域"; +"chats.channelInfo.region.allRegions" = "无范围"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@(默认)"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index d208d5754..91bf30f39 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "该文件无法用作个人头像。"; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "移动和缩放"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "选择"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "裁剪个人头像"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "上下轻扫可缩放。使用操作可移动照片。"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "上移"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "下移"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "左移"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "右移"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "昵称"; @@ -299,6 +323,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "最近广播"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "最后收到"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "未读消息"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings index fdf70d289..525ca44eb 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings @@ -335,7 +335,7 @@ "error.wifi.connectionFailed" = "连接失败:%@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "连接超时。请检查 IP 地址,并确保设备在同一网络中。"; +"error.wifi.connectionTimeout" = "连接超时。请检查主机名或 IP 地址,并确保可以连接到设备。"; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "未连接到设备。"; @@ -346,8 +346,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "发送操作超时。"; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "IP 地址无效。"; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "主机名或 IP 地址无效。"; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "端口号无效。"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index c92aa6a59..4550d49bd 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "最后广播"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "最后收到"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "公钥"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings index 202b528bb..9c8facb09 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings @@ -232,11 +232,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "连接详情"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP 地址"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "主机名或 IP 地址"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "清除 IP 地址"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "清除主机名或 IP 地址"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "端口"; @@ -245,7 +245,7 @@ "wifiConnection.port.clearAccessibility" = "清除端口"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "输入您的 MeshCore 设备的本地网络地址。默认端口为 5000。"; +"wifiConnection.connectionDetails.footer" = "输入您的 MeshCore 设备的主机名或 IP 地址。默认端口为 5000。"; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "连接中..."; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 9c1bafa35..e54ed09be 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -349,15 +349,24 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "保存到转发节点以在重启后保留更改。"; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "所有流量"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "无范围"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "*(所有流量)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "*(无范围)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "主区域"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "默认泛洪范围"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "无"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "为此节点发起的数据包(例如广播)设置范围。此设置会立即保存到转发节点。"; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ @@ -388,6 +397,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "添加区域失败"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "区域名称只能包含字母、数字和连字符。"; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "区域名称限制为 %d 字节。"; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "此区域已存在。"; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "移除区域失败"; diff --git a/MC1/Services/ImageURLDetector.swift b/MC1/Services/ImageURLDetector.swift index 5d8f69175..924b01049 100644 --- a/MC1/Services/ImageURLDetector.swift +++ b/MC1/Services/ImageURLDetector.swift @@ -10,7 +10,7 @@ enum ImageURLDetector { /// 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? { + static func downsampledImage(from data: Data, maxPixelSize: CGFloat = inlineMaxPixelSize) -> UIImage? { let options: [CFString: Any] = [ kCGImageSourceShouldCache: false ] @@ -19,7 +19,7 @@ enum ImageURLDetector { } let downsampleOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true ] diff --git a/MC1/Services/MessageTranslating.swift b/MC1/Services/MessageTranslating.swift new file mode 100644 index 000000000..6caf0e083 --- /dev/null +++ b/MC1/Services/MessageTranslating.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Test seam for on-device translation. Production wraps `TranslationSession` +/// inside the conversation `.translationTask` closure; tests inject a fake. +@MainActor +protocol MessageTranslating { + func translate(_ text: String) async throws -> String +} diff --git a/MC1/Services/MessageTranslationNeedsDownloadError.swift b/MC1/Services/MessageTranslationNeedsDownloadError.swift new file mode 100644 index 000000000..a1d3dd0e1 --- /dev/null +++ b/MC1/Services/MessageTranslationNeedsDownloadError.swift @@ -0,0 +1,13 @@ +import Foundation +@preconcurrency import Translation + +/// Installed-only session could not translate. Leave the pending request in +/// place so `.translationTask` can present the system download UI. +struct MessageTranslationNeedsDownloadError: Error { + static func wrapping(_ error: Error) -> Error { + if #available(iOS 26.0, *), TranslationError.notInstalled ~= error { + return MessageTranslationNeedsDownloadError() + } + return error + } +} diff --git a/MC1/Services/TranslationLanguageResolver.swift b/MC1/Services/TranslationLanguageResolver.swift new file mode 100644 index 000000000..bd6734d72 --- /dev/null +++ b/MC1/Services/TranslationLanguageResolver.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Maps compact detector/app-locale codes onto `LanguageAvailability.supportedLanguages`. +/// Compact codes make `status` report `.supported` even when the regional pack is installed. +enum TranslationLanguageResolver { + private enum Score { + static let specifiedScript = 4 + static let specifiedRegion = 2 + static let preferredRegion = 1 + } + + static func resolve( + _ code: String, + from supported: [Locale.Language], + preferring locale: Locale = .current + ) -> Locale.Language { + let requested = Locale.Language(identifier: code) + let matches = supported.filter { $0.languageCode == requested.languageCode } + guard !matches.isEmpty else { return requested } + + if !isCompact(code), + let exact = matches.first(where: { $0.maximalIdentifier == requested.maximalIdentifier }) { + return exact + } + + var best = matches[0] + var bestScore = score(best, code: code, requested: requested, locale: locale) + for candidate in matches.dropFirst() { + let value = score(candidate, code: code, requested: requested, locale: locale) + if value > bestScore { + best = candidate + bestScore = value + } + } + return best + } + + /// Language subtag only (`zh`, `en`). Foundation fills a default script/region + /// on `maximalIdentifier`, which must not count as an exact pack match. + private static func isCompact(_ code: String) -> Bool { + !code.contains("-") + } + + private static func score( + _ candidate: Locale.Language, + code: String, + requested: Locale.Language, + locale: Locale + ) -> Int { + var value = 0 + if isCompact(code) { + if candidate.script != nil, candidate.script == locale.language.script { + value += Score.specifiedScript + } + if candidate.region != nil, candidate.region == locale.region { + value += Score.preferredRegion + } + } else { + if candidate.script != nil, candidate.script == requested.script { + value += Score.specifiedScript + } + if requested.region != nil, candidate.region == requested.region { + value += Score.specifiedRegion + } + if requested.region == nil, candidate.region == locale.region { + value += Score.preferredRegion + } + } + return value + } +} diff --git a/MC1/Services/TranslationPerformResult.swift b/MC1/Services/TranslationPerformResult.swift new file mode 100644 index 000000000..75756c40b --- /dev/null +++ b/MC1/Services/TranslationPerformResult.swift @@ -0,0 +1,7 @@ +import Foundation + +enum TranslationPerformResult: Equatable, Sendable { + case finished + case needsDownload + case presentSystemOverlay(text: String) +} diff --git a/MC1/Services/TranslationSession+Configuration.swift b/MC1/Services/TranslationSession+Configuration.swift new file mode 100644 index 000000000..4113c8ce8 --- /dev/null +++ b/MC1/Services/TranslationSession+Configuration.swift @@ -0,0 +1,27 @@ +import Foundation +import Translation + +extension TranslationSession.Configuration { + /// Copy-mutate-reassign so a same-pair second message re-runs `.translationTask`. + /// `invalidate()` is mutating on a struct; optional-struct mutate in place does not publish. + static func replacing( + _ current: Self?, + source: Locale.Language, + target: Locale.Language + ) -> Self { + if var config = current, config.source == source, config.target == target { + config.invalidate() + return config + } + // highFidelity is Apple Intelligence; lowLatency is Translate language packs. + // Installed probe tries both; `.translationTask` prefers Intelligence. + if #available(iOS 26.4, *) { + return TranslationSession.Configuration( + source: source, + target: target, + preferredStrategy: .highFidelity + ) + } + return TranslationSession.Configuration(source: source, target: target) + } +} diff --git a/MC1/Services/TranslationSessionLauncher.swift b/MC1/Services/TranslationSessionLauncher.swift new file mode 100644 index 000000000..57e7f5198 --- /dev/null +++ b/MC1/Services/TranslationSessionLauncher.swift @@ -0,0 +1,90 @@ +import Foundation +@preconcurrency import Translation + +/// Installed-only `translate()` first. `.translationTask` can present a download +/// UI even when the pack is on disk, so fall back only on `notInstalled`. +enum TranslationSessionLauncher { + /// Runs `perform` on an installed-only session, or returns a `.translationTask` configuration. + @MainActor + static func launch( + request: TranslationSessionRequest, + replacing configuration: TranslationSession.Configuration?, + perform: (any MessageTranslating) async -> TranslationPerformResult + ) async -> TranslationSession.Configuration? { + let supported = await Self.translatePackLanguages() + guard !Task.isCancelled else { return nil } + let source = TranslationLanguageResolver.resolve( + request.sourceLanguageCode, + from: supported + ) + let target = TranslationLanguageResolver.resolve( + request.targetLanguageCode, + from: supported + ) + + if #available(iOS 26.0, *) { + for session in installedSessions(source: source, target: target) { + let result = await perform(InstalledPackTranslator(session: session)) + guard !Task.isCancelled else { return nil } + switch result { + case .finished, .presentSystemOverlay: + return nil + case .needsDownload: + continue + } + } + } + + return TranslationSession.Configuration.replacing( + configuration, + source: source, + target: target + ) + } + + /// Translate language packs, not the Apple Intelligence model list. + private nonisolated static func translatePackLanguages() async -> [Locale.Language] { + if #available(iOS 26.4, *) { + return await LanguageAvailability(preferredStrategy: .lowLatency).supportedLanguages + } + return await LanguageAvailability().supportedLanguages + } + + @available(iOS 26.0, *) + private static func installedSessions( + source: Locale.Language, + target: Locale.Language + ) -> [TranslationSession] { + if #available(iOS 26.4, *) { + return [ + TranslationSession( + installedSource: source, + target: target, + preferredStrategy: .highFidelity + ), + TranslationSession( + installedSource: source, + target: target, + preferredStrategy: .lowLatency + ) + ] + } + return [TranslationSession(installedSource: source, target: target)] + } + + /// `canRequestDownloads` is false, so `translate` throws `notInstalled` + /// instead of presenting the system download UI. + @MainActor + private struct InstalledPackTranslator: MessageTranslating { + let session: TranslationSession + + func translate(_ text: String) async throws -> String { + do { + let response = try await session.translate(text) + return response.targetText + } catch { + throw MessageTranslationNeedsDownloadError.wrapping(error) + } + } + } +} diff --git a/MC1/Services/TranslationSessionRequest.swift b/MC1/Services/TranslationSessionRequest.swift new file mode 100644 index 000000000..ed3b8cb51 --- /dev/null +++ b/MC1/Services/TranslationSessionRequest.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Pending Translation session request owned by the conversation view model. +/// Contains no Translation types so the view model stays Translation-free. +struct TranslationSessionRequest: Equatable, Hashable, Sendable { + let messageID: UUID + let sourceLanguageCode: String + let targetLanguageCode: String + let generation: UInt64 +} diff --git a/MC1/State/AppState+ChatPrefetch.swift b/MC1/State/AppState+ChatPrefetch.swift index d57d287dc..055c08f83 100644 --- a/MC1/State/AppState+ChatPrefetch.swift +++ b/MC1/State/AppState+ChatPrefetch.swift @@ -94,7 +94,8 @@ extension AppState { isOffline: !offlineMapService.isNetworkAvailable, currentUserName: localNodeName, themeID: themeID, - contentSizeCategory: contentSizeCategory + contentSizeCategory: contentSizeCategory, + preferredLanguageCode: EnvInputs.preferredLanguageCode(from: Locale.current) ) } @@ -152,7 +153,7 @@ extension AppState { /// Lazily builds the refresher that re-primes warm coordinators when /// messages arrive for closed conversations (see `ChatPrewarmRefresher`). /// Every hook resolves through `self` weakly at call time, so the refresher - /// stays valid across reconnects and registry rebinds. + /// stays valid across reconnects. func ensureChatPrewarmRefresher() -> ChatPrewarmRefresher { if let chatPrewarmRefresher { return chatPrewarmRefresher } let refresher = ChatPrewarmRefresher(hooks: ChatPrewarmRefresher.Hooks( diff --git a/MC1/State/AppState+Wiring.swift b/MC1/State/AppState+Wiring.swift index 01bc5adde..eea68fe5f 100644 --- a/MC1/State/AppState+Wiring.swift +++ b/MC1/State/AppState+Wiring.swift @@ -92,12 +92,17 @@ extension AppState { contactsVersion += 1 PersistentLogger(subsystem: "com.mc1", category: "discover-trace") .info("B4 contactUpdated bump contactsVersion=\(contactsVersion)") + case .conversationsChanged: + refreshConversations() 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, .nodeStorageFullChanged, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + .pathDiscoveryResponse, .traceResponse, .traceSnrObserved, + .orphanDirectMessagesAdopted: + // The paired .conversationsChanged already refreshes the list; the + // banner and badge are handled by the SyncCoordinator consumer. break } } diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index eacef1cbe..ceb8aa709 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -134,10 +134,8 @@ final class AppState { // 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. + /// Process-lifetime coordinator registry. Never nil'd after create — that + /// would strand a bound view model via `bindCoordinator`'s guard. private(set) var chatCoordinatorRegistry: ChatCoordinatorRegistry? /// Re-primes warm chat coordinators when messages arrive for closed @@ -155,37 +153,20 @@ final class AppState { /// and the shared decoded caches, same as the `\.linkPreviewCache` default. @ObservationIgnored lazy var backgroundLinkPreviewCache: any LinkPreviewCaching = LinkPreviewCache() - /// 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 + /// Process-lifetime store after a radio has been paired. Nil until then + /// so never-paired browsing stays empty. 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 + guard connectionManager.lastConnectedDeviceID != nil else { return nil } + return connectionManager.persistenceStore } - /// 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. + /// Sole lazy factory for the process-lifetime registry. Bound to the + /// process store; `wireServicesIfConnected` neither creates nor rebinds it. func ensureChatCoordinatorRegistry() -> ChatCoordinatorRegistry? { if let chatCoordinatorRegistry { return chatCoordinatorRegistry } guard let store = offlineDataStore else { return nil } @@ -226,18 +207,22 @@ final class AppState { } } - /// 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. + /// Bumps observer versions after a store-direct backup import so mounted + /// tabs reload. Also re-reads `regionSelection`, which `init` loads once. func notifyDataRestored() { contactsVersion += 1 conversationsVersion += 1 loadPersistedRegionSelection() themeService.refreshFromUserDefaults() + // Restore is refused while connected, so this cannot race a live session. + // Empties entries; the registry stays so later opens mint fresh coordinators. + chatCoordinatorRegistry?.clear() + bumpServicesVersion() + } + + /// Bumps `servicesVersion` so observing views re-run `performInitialLoad`. + func bumpServicesVersion() { + servicesVersion += 1 } // MARK: - Connection UI State @@ -347,9 +332,19 @@ final class AppState { // MARK: - Initialization - init(modelContainer: ModelContainer, isPlaceholder: Bool = false) { - let bootstrapStore = PersistenceStore(modelContainer: modelContainer) - let bootstrapBuffer = DebugLogBuffer(dataStore: bootstrapStore) + init( + modelContainer: ModelContainer, + isPlaceholder: Bool = false, + defaults: UserDefaults = .standard + ) { + let store = StoreService() + let theme = ThemeService(store: store) + storeState = StoreState(service: store) + themeService = theme + + connectionManager = ConnectionManager(modelContainer: modelContainer, defaults: defaults) + + let bootstrapBuffer = DebugLogBuffer(dataStore: connectionManager.persistenceStore) bootstrapDebugLogBuffer = bootstrapBuffer // The inert environment-default placeholder must not publish the process-global buffer, // or it would displace the live one and route logs into a discarded in-memory store. @@ -357,13 +352,6 @@ final class AppState { 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 @@ -396,6 +384,13 @@ final class AppState { await self?.wireServicesIfConnected() } + connectionManager.onLastConnectedDeviceCleared = { [weak self] in + guard let self else { return } + chatCoordinatorRegistry?.clear() + refreshConversations() + bumpServicesVersion() + } + // Wire auto-reconnect entry callback - reflects an out-of-range drop on the // Live Activity immediately, while connectionState is still .connecting. connectionManager.onAutoReconnectStarted = { [weak self] in @@ -474,10 +469,9 @@ final class AppState { ) } - /// 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. + /// Per-session teardown for connection-loss and explicit disconnect. + /// Cancels event tasks; the coordinator registry stays so an open chat + /// keeps its timeline. func tearDownAppStateSessionState() { settingsEventsTask?.cancel() settingsEventsTask = nil @@ -488,8 +482,6 @@ final class AppState { rxLogEventsTask?.cancel() rxLogEventsTask = nil messageEventDispatcher.cancelAll() - chatCoordinatorRegistry?.tearDown() - chatCoordinatorRegistry = nil navigation.clearPendingLinks() } @@ -555,12 +547,6 @@ final class AppState { DemoInlineImageSeeder.seed() } - if let existing = chatCoordinatorRegistry { - existing.rebind(dataStore: services.dataStore) - } else { - chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: services.dataStore) - } - wireSyncDataEvents(services: services) await wireSettingsEventStream(services: services) await wireDeviceUpdateCallbacks(services: services) diff --git a/MC1/State/ChatTimelinePrimer.swift b/MC1/State/ChatTimelinePrimer.swift index 85f268ddd..9b1356b86 100644 --- a/MC1/State/ChatTimelinePrimer.swift +++ b/MC1/State/ChatTimelinePrimer.swift @@ -76,7 +76,7 @@ final class ChatTimelinePrimer { // DM bubbles never show the sender row. senderTables = .empty case let .channel(channel): - // Contacts before the bake so `senderResolutionFor` resolves names. + // Contacts before bake so `senderResolutionFor` and `IncomingAvatarJPEGStore` see the table. senderTables = await fetchSenderTables(radioID: channel.radioID) } @@ -90,7 +90,7 @@ final class ChatTimelinePrimer { return ChatTimeline.ReactionIndexing(service: service, scope: scope) } - let outcome = await timeline.open(conversation, reactions: reactions) + let outcome = await timeline.open(conversation, reactions: reactions, populateMode: .replace) switch outcome { case .loaded: @@ -123,10 +123,20 @@ final class ChatTimelinePrimer { guard let dataStore = dependencies.dataStore() else { return .empty } do { let contacts = try await dataStore.fetchContacts(radioID: radioID) + try Task.checkCancellation() + let incomingAvatars = await incomingAvatarIdentitiesOffMain(from: contacts) + IncomingAvatarJPEGStore.replace( + contacts: contacts, + identities: Array(incomingAvatars.values) + ) + if Task.isCancelled { return .empty } return ChatSenderTables( contacts: contacts, - nicknamesByLoweredName: MessageBubbleConfiguration.buildNicknameLookup(from: contacts) + nicknamesByLoweredName: MessageBubbleConfiguration.buildNicknameLookup(from: contacts), + incomingAvatars: incomingAvatars ) + } catch is CancellationError { + return .empty } catch { logger.warning("Failed to load contacts for channel prime: \(error.localizedDescription)") return .empty @@ -160,3 +170,10 @@ final class ChatTimelinePrimer { } } } + +@concurrent +private func incomingAvatarIdentitiesOffMain( + from contacts: [ContactDTO] +) async -> [String: IncomingAvatarIdentity] { + MessageBubbleConfiguration.incomingAvatarIdentities(from: contacts) +} diff --git a/MC1/State/MessageEvent.swift b/MC1/State/MessageEvent.swift index b3593a5b6..fbb379054 100644 --- a/MC1/State/MessageEvent.swift +++ b/MC1/State/MessageEvent.swift @@ -30,6 +30,9 @@ enum MessageEvent: Equatable { case messageRetrying(messageID: UUID, attempt: Int, maxAttempts: Int) case heardRepeatRecorded(messageID: UUID, count: Int) case reactionReceived(messageID: UUID, summary: String) + /// Region reprocess rewrote dual region fields on these Message rows. + /// Consumers call `enqueueReload` so open chats re-bake region footers. + case messagesRegionUpdated(messageIDs: [UUID]) 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 226a3ad37..2d16bbcf3 100644 --- a/MC1/State/MessageEventDispatcher.swift +++ b/MC1/State/MessageEventDispatcher.swift @@ -32,6 +32,7 @@ final class MessageEventDispatcher { cancelAll() wireSyncCoordinator(services.syncCoordinator) wireHeardRepeats(services.heardRepeatsService) + wireRxLogRegionUpdates(services.rxLogService) wireRemoteNode(services.remoteNodeService) wireRoomServer(services.roomServerService) wireMessageService(services.messageService) @@ -85,6 +86,17 @@ final class MessageEventDispatcher { tasks.append(task) } + private func wireRxLogRegionUpdates(_ rxLogService: RxLogService) { + let events = rxLogService.regionUpdateEvents() + let task = Task { [stream] in + for await messageIDs in events { + guard !messageIDs.isEmpty else { continue } + stream.send(.messagesRegionUpdated(messageIDs: messageIDs)) + } + } + tasks.append(task) + } + private func wireRemoteNode(_ remoteNodeService: RemoteNodeService) { let events = remoteNodeService.events() let task = Task { [weak appState] in diff --git a/MC1/Utilities/PingHelper.swift b/MC1/Utilities/PingHelper.swift index cf5c335ce..1455e5c56 100644 --- a/MC1/Utilities/PingHelper.swift +++ b/MC1/Utilities/PingHelper.swift @@ -68,6 +68,18 @@ enum PingHelper { let elapsed = ContinuousClock.now - startTime let latencyMs = Int(elapsed / .milliseconds(1)) + if let radioID = appState.connectedDevice?.radioID { + do { + _ = try await services.dataStore.touchContactHeard( + radioID: radioID, + publicKey: contact.publicKey, + at: Date() + ) + } catch { + logger.error("Ping lastHeard stamp failed: \(error.localizedDescription)") + } + } + let announcement = L10n.Contacts.Contacts.Detail.pingSuccessAnnouncement(latencyMs) AccessibilityNotification.Announcement(announcement).post() return .success(latencyMs: latencyMs, snrThere: snrThere, snrBack: snrBack) diff --git a/MC1/Utilities/RepeaterResolver.swift b/MC1/Utilities/RepeaterResolver.swift index a32cc7d1e..8c2f3217b 100644 --- a/MC1/Utilities/RepeaterResolver.swift +++ b/MC1/Utilities/RepeaterResolver.swift @@ -218,8 +218,23 @@ enum NeighborNameResolver { )?.displayName } - static func fallbackName(for prefix: Data) -> String { - prefix.prefix(4).uppercaseHexString() + static let minimumKeyDisplayByteCount = 2 + static let maximumKeyDisplayByteCount = 3 + + /// Bytes of public-key prefix to show for a neighbour identity hex string. + /// Uses `DeviceDTO.hashSize` when present, clamped between `minimumKeyDisplayByteCount` + /// and `maximumKeyDisplayByteCount`. + static func keyDisplayByteCount(deviceHashSize: Int?) -> Int { + guard let size = deviceHashSize else { return minimumKeyDisplayByteCount } + return min(max(size, minimumKeyDisplayByteCount), maximumKeyDisplayByteCount) + } + + /// Uppercase hex of the leading `byteCount` bytes. + /// Clamps to `maximumKeyDisplayByteCount` and the available prefix so the full wire + /// prefix is never shown as a fallback title. + static func fallbackName(for prefix: Data, byteCount: Int) -> String { + let n = min(max(byteCount, 0), maximumKeyDisplayByteCount, prefix.count) + return prefix.prefix(n).uppercaseHexString() } /// Resolves every hop of a node's stored path to a repeater name, falling back to a placeholder diff --git a/MC1/Views/Chats/ChannelConversationRow.swift b/MC1/Views/Chats/ChannelConversationRow.swift index 937a172f9..9edf5dc49 100644 --- a/MC1/Views/Chats/ChannelConversationRow.swift +++ b/MC1/Views/Chats/ChannelConversationRow.swift @@ -41,6 +41,10 @@ struct ChannelConversationRow: View { Spacer() + if viewModel.conversationHasFailedSend(channel.id) { + FailedSendIndicator() + } + UnreadBadges( unreadCount: channel.unreadCount, unreadMentionCount: channel.unreadMentionCount, diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index afed2e37b..76dd477fb 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -42,6 +42,9 @@ struct ChatConversationMessagesContent: View { @Environment(\.appTheme) private var theme @Environment(\.openURL) private var openURL + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var incomingAvatarFlight = IncomingAvatarFlight() // MARK: - Body @@ -75,17 +78,35 @@ struct ChatConversationMessagesContent: View { private var messagesList: some View { ChatTiledView( items: viewModel.items, - cellContent: cellFactory.makeContent(for:), + cellContent: { item in + cellFactory.makeContent(for: item) + .environment(\.incomingAvatarFlight, incomingAvatarFlight) + }, contentBackground: theme.surfaces?.canvas, isAtBottom: $isAtBottom, unreadCount: $unreadCount, scrollToBottomRequest: scrollToBottomRequest, + countsTowardUnread: { !$0.envelope.isOutgoing }, scrollToTargetRequest: scrollToTargetRequest, scrollTargetID: scrollToTargetID, initialScrollTargetID: initialScrollTargetID, onLoadOlder: { await viewModel.loadOlderMessages() }, onInitialTargetConsumed: onDividerTargetConsumed ) + .overlay { + incomingAvatarFlight.overlay() + } + .onAppear { + viewModel.incomingAvatarFlight = incomingAvatarFlight + incomingAvatarFlight.isAtBottom = isAtBottom + incomingAvatarFlight.reduceMotion = reduceMotion + } + .onChange(of: isAtBottom, initial: true) { _, atBottom in + incomingAvatarFlight.isAtBottom = atBottom + } + .onChange(of: reduceMotion, initial: true) { _, reduce in + incomingAvatarFlight.reduceMotion = reduce + } .onChange(of: envInputs) { _, new in viewModel.applyEnvInputs(new) } @@ -136,7 +157,10 @@ struct ChatConversationMessagesContent: View { }, snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, requestSnapshot: { MapSnapshotStore.shared.request($0) }, - retrySnapshot: { MapSnapshotStore.shared.retry($0) } + retrySnapshot: { MapSnapshotStore.shared.retry($0) }, + onTranslationAction: { messageID in + viewModel.performTranslationAction(for: messageID) + } ) ) } @@ -233,7 +257,8 @@ private struct ChannelEmptyMessagesView: View { conversationType: .dm(ContactDTO(from: Contact( radioID: UUID(), publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" + name: "Alice", + lastHeardTimestamp: 0 ))), viewModel: ChatViewModel(), deviceName: "My Device", diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index eb3eac042..456bf4468 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -1,6 +1,7 @@ import MC1Services import OSLog import SwiftUI +import Translation import UIKit // UIPasteboard for .copy action private let logger = Logger(subsystem: "com.mc1", category: "ChatConversationView") @@ -44,6 +45,9 @@ struct ChatConversationView: View { @State private var blockSenderContext: BlockSenderContext? @State private var sendDMContext: SendDMContext? @State private var imageViewerData: ImageViewerData? + @State private var translationConfiguration: TranslationSession.Configuration? + @State private var systemTranslationText = "" + @State private var showSystemTranslation = false // MARK: - Other State @@ -70,6 +74,7 @@ struct ChatConversationView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.appTheme) private var theme + @Environment(\.locale) private var locale /// Snapshot of env-derived inputs the view model needs to construct /// MessageItems at write time. Recomputed on every render — Equatable @@ -92,7 +97,8 @@ struct ChatConversationView: View { isOffline: !appState.offlineMapService.isNetworkAvailable, currentUserName: appState.localNodeName, themeID: theme.id, - contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize), + preferredLanguageCode: EnvInputs.preferredLanguageCode(from: locale) ) } @@ -224,12 +230,12 @@ struct ChatConversationView: View { chatViewModel: chatViewModel, onClearChannelMessages: { guard case let .channel(channel) = conversationType else { return } - await chatViewModel.loadChannelMessages(for: channel) + await chatViewModel.loadChannelMessages(for: channel, populateMode: .replace) parentViewModel?.requestConversationReload() }, onClearDirectMessages: { guard case let .dm(contact) = conversationType else { return } - await chatViewModel.loadMessages(for: contact) + await chatViewModel.loadMessages(for: contact, populateMode: .replace) parentViewModel?.requestConversationReload() }, onDeleteChannel: { @@ -276,11 +282,34 @@ struct ChatConversationView: View { .task(id: appState.servicesVersion) { await performInitialLoad() } + .conversationTranslationSession( + configuration: $translationConfiguration, + request: $chatViewModel.translationSessionRequest, + perform: { translator, request in + let result = await chatViewModel.performPendingTranslation(using: translator, for: request) + if case let .presentSystemOverlay(text: text) = result { + systemTranslationText = text + showSystemTranslation = true + } + return result + } + ) + // Overlay after `.conversationTranslationSession`: `.translationPresentation` + // must not sit in that modifier's `.translationTask` content tree. + .background { + Color.clear + .accessibilityHidden(true) + .translationPresentation( + isPresented: $showSystemTranslation, + text: systemTranslationText + ) + } .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() + chatViewModel.cancelPendingTranslation() performCleanup() } .onChange(of: chatViewModel.composingText) { _, _ in @@ -358,7 +387,7 @@ struct ChatConversationView: View { switch conversationType { case let .dm(contact): - await chatViewModel.loadMessages(for: contact) + await chatViewModel.loadMessages(for: contact, populateMode: .refreshWindow) await chatViewModel.loadConversations(radioID: contact.radioID) await chatViewModel.loadAllContacts(radioID: contact.radioID) chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) @@ -366,7 +395,7 @@ struct ChatConversationView: View { 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.loadChannelMessages(for: channel, populateMode: .refreshWindow) await chatViewModel.loadConversations(radioID: channel.radioID) chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) } @@ -615,6 +644,8 @@ struct ChatConversationView: View { handleReply(for: message) case .copy: handleCopy(for: message) + case .translate: + presentSystemTranslation(text: message.text) case .sendAgain: handleSendAgain(for: message) case .blockSender: @@ -655,7 +686,15 @@ struct ChatConversationView: View { } private func handleCopy(for message: MessageDTO) { - UIPasteboard.general.string = message.text + UIPasteboard.general.string = chatViewModel.displayedText(for: message) + } + + private func presentSystemTranslation(text: String) { + systemTranslationText = text + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + showSystemTranslation = true + } } private func handleSendAgain(for message: MessageDTO) { @@ -733,7 +772,7 @@ struct ChatConversationView: View { } if case let .channel(channel) = conversationType { - await chatViewModel.loadChannelMessages(for: channel) + await chatViewModel.loadChannelMessages(for: channel, populateMode: .replace) } services.syncCoordinator.notifyConversationsChanged() } @@ -772,7 +811,8 @@ private extension View { conversationType: .dm(ContactDTO(from: Contact( radioID: UUID(), publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" + name: "Alice", + lastHeardTimestamp: 0 ))) ) } diff --git a/MC1/Views/Chats/ChatPopulateMode.swift b/MC1/Views/Chats/ChatPopulateMode.swift new file mode 100644 index 000000000..f2f0f97cd --- /dev/null +++ b/MC1/Views/Chats/ChatPopulateMode.swift @@ -0,0 +1,10 @@ +import Foundation + +/// How a conversation load fills the timeline. +enum ChatPopulateMode { + /// Fetch the first page and replace the loaded window. + case replace + /// Refetch the already-loaded window in place. Falls back to `replace` + /// sizing when this conversation is not already presenting. + case refreshWindow +} diff --git a/MC1/Views/Chats/ChatsListModifiers.swift b/MC1/Views/Chats/ChatsListModifiers.swift index 8d3b89dfc..6f64e6732 100644 --- a/MC1/Views/Chats/ChatsListModifiers.swift +++ b/MC1/Views/Chats/ChatsListModifiers.swift @@ -72,6 +72,13 @@ struct ChatsListModifiers: ViewModifier { onHandlePendingChannelNavigation() onHandlePendingRoomNavigation() } + .task { + for await event in appState.messageEventStream.events() { + if viewModel.shouldRefreshFailedSendIndicators(for: event) { + await viewModel.refreshFailedSendIndicators() + } + } + } .onChange(of: appState.navigation.pendingChatContact) { _, _ in onHandlePendingNavigation() } diff --git a/MC1/Views/Chats/Components/BubbleActions.swift b/MC1/Views/Chats/Components/BubbleActions.swift index 0fdc31753..e06bb53e8 100644 --- a/MC1/Views/Chats/Components/BubbleActions.swift +++ b/MC1/Views/Chats/Components/BubbleActions.swift @@ -31,4 +31,5 @@ struct BubbleActions { let snapshotResolver: (MapSnapshotRequest) -> UIImage? let requestSnapshot: (MapSnapshotRequest) -> Void let retrySnapshot: (MapSnapshotRequest) -> Void + let onTranslationAction: (UUID) -> Void } diff --git a/MC1/Views/Chats/Components/BubbleFooterRow.swift b/MC1/Views/Chats/Components/BubbleFooterRow.swift index ce06245e6..02e6bf031 100644 --- a/MC1/Views/Chats/Components/BubbleFooterRow.swift +++ b/MC1/Views/Chats/Components/BubbleFooterRow.swift @@ -78,6 +78,7 @@ struct BubbleFooterRow: View { if let region = footer.regionToShow { badges.append(AnyView(BubbleRegionFooter( regionName: region, + matchNames: footer.regionMatchNames, allowsWrap: dynamicTypeSize.isAccessibilitySize ))) } @@ -167,19 +168,45 @@ private struct BubblePathFooter: View { private struct BubbleRegionFooter: View { let regionName: String + let matchNames: [String] let allowsWrap: Bool + private var isAmbiguous: Bool { + matchNames.count > 1 + } + var body: some View { HStack(spacing: 2) { Image(systemName: "globe") Text(regionName) .lineLimit(allowsWrap ? nil : 1) + if isAmbiguous { + // Explicit region L10n only; do not use path-hop default strings. + let listBody = "\n" + matchNames.joined(separator: "\n") + FallbackMatchIndicatorView( + accessibilityLabel: L10n.Chats.Chats.Message.Region.Ambiguous.possibleMatch, + accessibilityHint: L10n.Chats.Chats.Message.Region.Ambiguous.possibleMatchHint, + title: L10n.Chats.Chats.Message.Region.Ambiguous.popoverTitle, + explanation: L10n.Chats.Chats.Message.Region.Ambiguous.popoverBody(listBody) + ) + } } .font(.caption2) .foregroundStyle(.secondary) .footerChip(color: .secondary) .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName)) + .accessibilityLabel(MessageRegionAccessibility.label(regionName: regionName, matchNames: matchNames)) + } +} + +/// Shared VoiceOver label for the region chip and the whole-bubble combined label. +enum MessageRegionAccessibility { + static func label(regionName: String, matchNames: [String]) -> String { + if matchNames.count > 1 { + let list = ListFormatter.localizedString(byJoining: matchNames) + return L10n.Chats.Chats.Message.Region.ambiguousAccessibilityLabel(list) + } + return L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName) } } diff --git a/MC1/Views/Chats/Components/BubbleFragmentStack.swift b/MC1/Views/Chats/Components/BubbleFragmentStack.swift index 8845f21f7..f7b9cab32 100644 --- a/MC1/Views/Chats/Components/BubbleFragmentStack.swift +++ b/MC1/Views/Chats/Components/BubbleFragmentStack.swift @@ -51,6 +51,15 @@ struct BubbleFragmentStack: View, Equatable { if case let .disabled(url) = layout.inlineImage?.state { url } else { nil } } + /// Showing uses the translated string as plain `Text` so mention/hashtag runs are not applied. + private func translationBody(_ textPayload: MessageTextPayload) -> Text { + if case let .showing(translated, _) = textPayload.translation?.phase { + Text(translated) + } else { + Text(textPayload.formatted ?? AttributedString(textPayload.raw)) + } + } + var body: some View { let stack = VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { // Stack alignment carries the footer placement: the time sits at the @@ -60,11 +69,17 @@ struct BubbleFragmentStack: View, Equatable { // self-sizing hosting cell without a resolvable intrinsic width, which // SwiftUI surfaces as a fatal "invalid reuse after initialization failure". VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 2) { + if let chrome = layout.textPayload?.translation { + BubbleTranslationControl( + phase: chrome.phase, + onTap: { callbacks.onTranslationAction?() } + ) + } if let textPayload = layout.textPayload { // Native `Text`: the precomputed `formatted` string already carries every run's color, // underline, bold, and `.link`, so link taps route through the injected `\.openURL` and // Dynamic Type scales for free. `foregroundStyle` colors the raw fallback when unformatted. - Text(textPayload.formatted ?? AttributedString(textPayload.raw)) + translationBody(textPayload) .font(.body) .foregroundStyle(textPayload.baseColor.swiftUIColor) } diff --git a/MC1/Views/Chats/Components/BubbleTranslationControl.swift b/MC1/Views/Chats/Components/BubbleTranslationControl.swift new file mode 100644 index 000000000..631b8d1b8 --- /dev/null +++ b/MC1/Views/Chats/Components/BubbleTranslationControl.swift @@ -0,0 +1,51 @@ +import MC1Services +import SwiftUI + +/// In-bubble Translation offer. Not a `Button`, so `tapYieldingToLongPress` can +/// yield to the bubble long-press. VoiceOver-hidden; the bubble exposes the action. +struct BubbleTranslationControl: View { + let phase: MessageTranslationChrome.Phase + let onTap: () -> Void + + var body: some View { + label + .font(.caption) + .imageScale(.small) + .labelStyle(.titleAndIcon) + .foregroundStyle(Color.accentColor) + .tint(Color.accentColor) + .multilineTextAlignment(.leading) + .contentShape(.rect) + .accessibilityHidden(true) + .tapYieldingToLongPress { + if case .inProgress = phase { return } + onTap() + } + } + + static func title(for phase: MessageTranslationChrome.Phase) -> String { + switch phase { + case .offer: + L10n.Chats.Chats.Message.Action.translate + case .inProgress: + L10n.Chats.Chats.Message.Action.translating + case .showing: + L10n.Chats.Chats.Message.Action.showOriginal + } + } + + @ViewBuilder + private var label: some View { + switch phase { + case .offer, .showing: + Label(Self.title(for: phase), systemImage: "translate") + case .inProgress: + Label { + Text(Self.title(for: phase)) + } icon: { + ProgressView() + .controlSize(.mini) + } + } + } +} diff --git a/MC1/Views/Chats/Components/ChatKeyboardLift.swift b/MC1/Views/Chats/Components/ChatKeyboardLift.swift index af1d43058..3eb2867fa 100644 --- a/MC1/Views/Chats/Components/ChatKeyboardLift.swift +++ b/MC1/Views/Chats/Components/ChatKeyboardLift.swift @@ -66,6 +66,19 @@ enum ChatKeyboardLift { for: nil ) } + + /// Interactive pop posts willHide while the keyboard window stays; keep the + /// current lift. Back-button and drag-to-dismiss still apply `proposed`. + static func resolvedLift( + current: CGFloat, + proposed: CGFloat, + isInteractivePopActive: Bool + ) -> CGFloat { + if isInteractivePopActive, proposed < current { + return current + } + return proposed + } } extension EnvironmentValues { @@ -79,11 +92,18 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var lift: CGFloat = 0 + @State private var interactivePop = InteractivePopProbe() func body(content: Content) -> some View { content .environment(\.chatKeyboardLift, lift) .ignoresSafeArea(.keyboard, edges: .bottom) + .background { + InteractiveNavigationPopObserver(probe: interactivePop) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .allowsHitTesting(false) + } .onReceive( NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification) ) { notification in @@ -92,7 +112,7 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { .onReceive( NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) ) { notification in - setLift(0, userInfo: notification.userInfo) + applyProposedLift(0, userInfo: notification.userInfo) } .onChange(of: scenePhase) { _, phase in switch phase { @@ -113,7 +133,7 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, let window = ChatKeyboardLift.keyWindow() else { - setLift(0, userInfo: notification.userInfo) + applyProposedLift(0, userInfo: notification.userInfo) return } let keyboardInWindow = window.convert(frame, from: nil) @@ -122,7 +142,16 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { windowBounds: window.bounds, bottomSafeArea: window.safeAreaInsets.bottom ) - setLift(next, userInfo: notification.userInfo) + applyProposedLift(next, userInfo: notification.userInfo) + } + + private func applyProposedLift(_ proposed: CGFloat, userInfo: [AnyHashable: Any]?) { + let next = ChatKeyboardLift.resolvedLift( + current: lift, + proposed: proposed, + isInteractivePopActive: interactivePop.isInteractivePopActive + ) + setLift(next, userInfo: userInfo) } private func setLift(_ value: CGFloat, userInfo: [AnyHashable: Any]?) { @@ -150,6 +179,84 @@ private struct ChatKeyboardLiftPaddingModifier: ViewModifier { } } +/// Live pop signal: `initiallyInteractive` covers swipe through cancel/commit; +/// gesture `.began`/`.changed` covers willHide before a coordinator exists. +@MainActor +private final class InteractivePopProbe { + weak var navigationController: UINavigationController? + + var isInteractivePopActive: Bool { + guard let navigationController else { return false } + if navigationController.transitionCoordinator?.initiallyInteractive == true { + return true + } + if isPopGestureActive(navigationController.interactivePopGestureRecognizer) { + return true + } + if #available(iOS 26, *) { + if isPopGestureActive(navigationController.interactiveContentPopGestureRecognizer) { + return true + } + } + return false + } + + private func isPopGestureActive(_ gesture: UIGestureRecognizer?) -> Bool { + switch gesture?.state { + case .began, .changed: + true + default: + false + } + } +} + +private struct InteractiveNavigationPopObserver: UIViewControllerRepresentable { + let probe: InteractivePopProbe + + func makeUIViewController(context: Context) -> Controller { + Controller(probe: probe) + } + + func updateUIViewController(_ uiViewController: Controller, context: Context) { + uiViewController.probe = probe + } + + static func dismantleUIViewController(_ uiViewController: Controller, coordinator: Void) { + uiViewController.clearNavigationController() + } + + final class Controller: UIViewController { + var probe: InteractivePopProbe + + init(probe: InteractivePopProbe) { + self.probe = probe + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + probe.navigationController = navigationController + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + clearNavigationController() + } + + func clearNavigationController() { + if probe.navigationController === navigationController { + probe.navigationController = nil + } + } + } +} + extension View { /// Owns keyboard avoidance for chat conversation hosts: ignores system /// keyboard safe area and publishes `chatKeyboardLift` for bottom chrome. diff --git a/MC1/Views/Chats/Components/ChatTiledView.swift b/MC1/Views/Chats/Components/ChatTiledView.swift index 6b0ace8b9..d8bcd1b43 100644 --- a/MC1/Views/Chats/Components/ChatTiledView.swift +++ b/MC1/Views/Chats/Components/ChatTiledView.swift @@ -19,9 +19,13 @@ struct ChatTiledView: V @Binding var isAtBottom: Bool @Binding var unreadCount: Int - /// Bumped by callers to scroll to the visual bottom (e.g. on send). + /// Bumped by callers to pin the visual bottom. Honored only while `isAtBottom` + /// is already true; `ScrollToBottomButton` calls `scrollPosition.scrollTo` itself. var scrollToBottomRequest: Int = 0 + /// Returns whether an appended row raises the unread badge while scrolled up. + var countsTowardUnread: (Item) -> Bool = { _ in true } + /// Bumped by callers to jump to `scrollTargetID` (mention / reply / deeplink / divider). var scrollToTargetRequest: Int = 0 var scrollTargetID: Item.ID? @@ -56,6 +60,7 @@ struct ChatTiledView: V isAtBottom: Binding, unreadCount: Binding, scrollToBottomRequest: Int = 0, + countsTowardUnread: @escaping (Item) -> Bool = { _ in true }, scrollToTargetRequest: Int = 0, scrollTargetID: Item.ID? = nil, initialScrollTargetID: Item.ID? = nil, @@ -68,6 +73,7 @@ struct ChatTiledView: V _isAtBottom = isAtBottom _unreadCount = unreadCount self.scrollToBottomRequest = scrollToBottomRequest + self.countsTowardUnread = countsTowardUnread self.scrollToTargetRequest = scrollToTargetRequest self.scrollTargetID = scrollTargetID self.initialScrollTargetID = initialScrollTargetID @@ -123,7 +129,10 @@ struct ChatTiledView: V .padding(.trailing, 16) .padding(.bottom, 8) } - .onChange(of: scrollToBottomRequest) { scrollPosition.scrollTo(edge: .bottom) } + .onChange(of: scrollToBottomRequest) { + guard isAtBottom else { return } + scrollPosition.scrollTo(edge: .bottom, animated: false) + } .onChange(of: scrollToTargetRequest) { guard let id = scrollTargetID else { return } scrollPosition.scrollTo(id: id) @@ -132,8 +141,8 @@ struct ChatTiledView: V defer { newestID = latest } guard !isAtBottom, let previous = newestID, let previousIndex = items.firstIndex(where: { $0.id == previous }) else { return } - let appended = items.count - 1 - previousIndex - if appended > 0 { unreadCount += appended } + let incoming = items.suffix(from: previousIndex + 1).filter(countsTowardUnread).count + if incoming > 0 { unreadCount += incoming } } } diff --git a/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift b/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift new file mode 100644 index 000000000..939726ace --- /dev/null +++ b/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift @@ -0,0 +1,73 @@ +import SwiftUI +@preconcurrency import Translation + +/// Shared Chat/Room `.translationTask` wiring. `.task(id:)` cancels launch when +/// the request changes; `.translationTask` performs `sessionBoundRequest`. +struct ConversationTranslationSessionModifier: ViewModifier { + @Binding var configuration: TranslationSession.Configuration? + @Binding var request: TranslationSessionRequest? + let perform: @MainActor (any MessageTranslating, TranslationSessionRequest) async -> TranslationPerformResult + + /// Request that produced the current `configuration`. Assigned in the same + /// turn as `configuration`. + @State private var sessionBoundRequest: TranslationSessionRequest? + + func body(content: Content) -> some View { + content + .translationTask(configuration) { session in + guard let sessionBoundRequest, + request?.generation == sessionBoundRequest.generation else { return } + // Overlay presentation is a `perform` side effect so `.translationTask` + // and TranslationSessionLauncher share one closure. + _ = await perform( + TranslationSessionMessageTranslator(session: session), + sessionBoundRequest + ) + } + .task(id: request) { + guard let launchRequest = request else { + configuration = nil + sessionBoundRequest = nil + return + } + let result = await TranslationSessionLauncher.launch( + request: launchRequest, + replacing: configuration + ) { translator in + await perform(translator, launchRequest) + } + guard !Task.isCancelled, + self.request?.generation == launchRequest.generation else { return } + configuration = result + sessionBoundRequest = result == nil ? nil : launchRequest + } + } +} + +extension View { + func conversationTranslationSession( + configuration: Binding, + request: Binding, + perform: @escaping @MainActor (any MessageTranslating, TranslationSessionRequest) async -> TranslationPerformResult + ) -> some View { + modifier( + ConversationTranslationSessionModifier( + configuration: configuration, + request: request, + perform: perform + ) + ) + } +} + +extension ConversationTranslationSessionModifier { + @MainActor + private struct TranslationSessionMessageTranslator: MessageTranslating { + let session: TranslationSession + + func translate(_ text: String) async throws -> String { + let response = try await session.translate(text) + return response.targetText + } + } +} diff --git a/MC1/Views/Chats/Components/IncomingAvatarFlight.swift b/MC1/Views/Chats/Components/IncomingAvatarFlight.swift new file mode 100644 index 000000000..fb1250a52 --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingAvatarFlight.swift @@ -0,0 +1,167 @@ +import MC1Services +import SwiftUI + +/// Owns the in-flight cluster-end avatar. Gutters read flying IDs only; the +/// overlay reads frames so scroll-time `reportFrame` does not invalidate rows. +@Observable +@MainActor +final class IncomingAvatarFlight { + struct Flight: Equatable { + let fromID: UUID + let toID: UUID + let identity: IncomingAvatarIdentity + let initialFromFrame: CGRect + } + + /// Skip the drop when the conversation is scrolled up. Default matches a + /// freshly opened thread sitting at the bottom. + var isAtBottom = true + + /// Skip the drop under Reduce Motion; the in-cell avatar swaps instantly. + var reduceMotion = false + + private(set) var flight: Flight? + private(set) var flyingFromID: UUID? + private(set) var flyingToID: UUID? + private var frames: [UUID: CGRect] = [:] + + func isFlying(_ id: UUID) -> Bool { + id == flyingFromID || id == flyingToID + } + + func shouldReportFrame(messageID: UUID, hasIdentity: Bool) -> Bool { + if flyingFromID != nil { + return messageID == flyingFromID || messageID == flyingToID + } + return hasIdentity + } + + func frame(for id: UUID) -> CGRect? { + frames[id] + } + + func drawnFrame(progress: CGFloat) -> CGRect? { + guard let flight else { return nil } + let from = frames[flight.fromID] ?? flight.initialFromFrame + let to = frames[flight.toID] ?? from + return Self.lerp(from: from, to: to, progress: progress) + } + + func beginFlight(from: UUID, to: UUID, identity: IncomingAvatarIdentity) { + mutateWithoutAnimation { + if flight != nil { + clearFlight() + return + } + guard isAtBottom, !reduceMotion else { return } + guard let fromFrame = frames[from] else { return } + flyingFromID = from + flyingToID = to + flight = Flight( + fromID: from, + toID: to, + identity: identity, + initialFromFrame: fromFrame + ) + } + } + + func reportFrame(_ frame: CGRect, for id: UUID) { + mutateWithoutAnimation { + frames[id] = frame + } + } + + func unbind(_ id: UUID) { + mutateWithoutAnimation { + frames.removeValue(forKey: id) + } + } + + func complete() { + mutateWithoutAnimation { + clearFlight() + } + } + + func overlay() -> some View { + Overlay(controller: self) + } + + static func lerp(from: CGRect, to: CGRect, progress: CGFloat) -> CGRect { + let p = min(max(progress, 0), 1) + return CGRect( + x: from.origin.x + (to.origin.x - from.origin.x) * p, + y: from.origin.y + (to.origin.y - from.origin.y) * p, + width: from.size.width + (to.size.width - from.size.width) * p, + height: from.size.height + (to.size.height - from.size.height) * p + ) + } + + private func clearFlight() { + if let from = flyingFromID { + frames.removeValue(forKey: from) + } + flyingFromID = nil + flyingToID = nil + flight = nil + } + + private func mutateWithoutAnimation(_ body: () -> Void) { + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction, body) + } +} + +extension EnvironmentValues { + @Entry var incomingAvatarFlight: IncomingAvatarFlight? +} + +extension IncomingAvatarFlight { + private struct Overlay: View { + var controller: IncomingAvatarFlight + @State private var progress: CGFloat = 0 + + var body: some View { + Group { + if let flight = controller.flight { + GeometryReader { geo in + let overlayGlobal = geo.frame(in: .global) + if let drawn = controller.drawnFrame(progress: progress) { + let local = drawn.offsetBy(dx: -overlayGlobal.minX, dy: -overlayGlobal.minY) + ContactAvatar( + name: flight.identity.name, + size: IncomingBubbleAvatarMetrics.size, + imageData: IncomingAvatarJPEGStore.data(for: flight.identity.matchedContactID) + ) + .frame( + width: IncomingBubbleAvatarMetrics.size, + height: IncomingBubbleAvatarMetrics.size + ) + .offset(x: local.minX, y: local.minY) + .accessibilityHidden(true) + .allowsHitTesting(false) + } + } + } + } + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityHidden(true) + .onChange(of: controller.flight?.toID, initial: true) { _, toID in + var reset = Transaction() + reset.animation = nil + withTransaction(reset) { progress = 0 } + guard let toID else { return } + withAnimation(.smooth(duration: IncomingBubbleAvatarMetrics.flightDuration)) { + progress = 1 + } completion: { + if controller.flight?.toID == toID { + controller.complete() + } + } + } + } + } +} diff --git a/MC1/Views/Chats/Components/IncomingAvatarGutter.swift b/MC1/Views/Chats/Components/IncomingAvatarGutter.swift new file mode 100644 index 000000000..6c1f466a0 --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingAvatarGutter.swift @@ -0,0 +1,67 @@ +import MC1Services +import SwiftUI + +struct IncomingAvatarGutter: View { + let identity: IncomingAvatarIdentity? + let reserveColumn: Bool + let messageID: UUID + @Environment(\.incomingAvatarFlight) private var flight + @ViewBuilder var content: Content + + var body: some View { + let isFlying = flight?.isFlying(messageID) == true + let shouldReport = flight?.shouldReportFrame( + messageID: messageID, + hasIdentity: identity != nil + ) == true + + Group { + if let identity { + HStack(alignment: .bottom, spacing: IncomingBubbleAvatarMetrics.gap) { + ContactAvatar( + name: identity.name, + size: IncomingBubbleAvatarMetrics.size, + imageData: IncomingAvatarJPEGStore.data(for: identity.matchedContactID) + ) + .opacity(isFlying ? 0 : 1) + .accessibilityHidden(true) + .onGeometryChange(for: CGRect.self) { proxy in + shouldReport ? proxy.frame(in: .global) : .null + } action: { global in + guard shouldReport, global != .null else { return } + flight?.reportFrame(global, for: messageID) + } + content + } + .contentShape(.rect) + } else if reserveColumn { + content + .padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) + .background(alignment: .bottomLeading) { + if shouldReport { + Color.clear + .frame( + width: IncomingBubbleAvatarMetrics.size, + height: IncomingBubbleAvatarMetrics.size + ) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { global in + flight?.reportFrame(global, for: messageID) + } + } + } + } else { + content + } + } + .onDisappear { flight?.unbind(messageID) } + .onChange(of: messageID) { oldID, _ in + flight?.unbind(oldID) + } + .onChange(of: identity) { _, newIdentity in + guard newIdentity == nil, flight?.isFlying(messageID) != true else { return } + flight?.unbind(messageID) + } + } +} diff --git a/MC1/Views/Chats/Components/IncomingAvatarJPEGStore.swift b/MC1/Views/Chats/Components/IncomingAvatarJPEGStore.swift new file mode 100644 index 000000000..41f5dd946 --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingAvatarJPEGStore.swift @@ -0,0 +1,41 @@ +import Foundation +import MC1Services + +/// Process-lifetime JPEG bytes for channel cluster-end photos. Replace-all +/// before bake or patch so first paint never hits an empty map with a live revision. +/// Tests bind `isolatedStorage` so parallel suites do not share this map. +@MainActor +enum IncomingAvatarJPEGStore { + @MainActor + final class Storage { + var jpegByContactID: [UUID: Data] = [:] + } + + @TaskLocal static var isolatedStorage: Storage? + + private static let processStorage = Storage() + private static var storage: Storage { + isolatedStorage ?? processStorage + } + + static func replace(contacts: [ContactDTO], identities: [IncomingAvatarIdentity]) { + let jpegByID: [UUID: Data] = Dictionary( + uniqueKeysWithValues: contacts.compactMap { contact in + guard let data = contact.avatarImageData, !data.isEmpty else { return nil } + return (contact.id, data) + } + ) + var next: [UUID: Data] = [:] + for identity in identities { + guard let contactID = identity.matchedContactID, + let data = jpegByID[contactID] else { continue } + next[contactID] = data + } + storage.jpegByContactID = next + } + + static func data(for contactID: UUID?) -> Data? { + guard let contactID else { return nil } + return storage.jpegByContactID[contactID] + } +} diff --git a/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift b/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift new file mode 100644 index 000000000..56f05316e --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift @@ -0,0 +1,11 @@ +import CoreGraphics +import Foundation + +enum IncomingBubbleAvatarMetrics { + static let size: CGFloat = 28 + static let gap: CGFloat = 6 + static let flightDuration: TimeInterval = 0.25 + static var columnWidth: CGFloat { + size + gap + } +} diff --git a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift index cc472f39e..c3644a633 100644 --- a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift +++ b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift @@ -19,4 +19,5 @@ struct MessageBubbleCallbacks { var snapshotResolver: ((MapSnapshotRequest) -> UIImage?)? var requestSnapshot: ((MapSnapshotRequest) -> Void)? var retrySnapshot: ((MapSnapshotRequest) -> Void)? + var onTranslationAction: (() -> Void)? } diff --git a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift index 107fdac52..0aabe1102 100644 --- a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift +++ b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift @@ -3,11 +3,15 @@ import MC1Services /// View-layer formatting flags for a message bubble. struct MessageBubbleConfiguration { var showSenderName: Bool + var showsIncomingAvatars: Bool - static let directMessage = MessageBubbleConfiguration(showSenderName: false) + static let directMessage = MessageBubbleConfiguration( + showSenderName: false, + showsIncomingAvatars: false + ) static func channel(isPublic: Bool) -> MessageBubbleConfiguration { - MessageBubbleConfiguration(showSenderName: true) + MessageBubbleConfiguration(showSenderName: true, showsIncomingAvatars: true) } /// Builds a `loweredName -> nickname` lookup for channel sender matching. @@ -31,9 +35,30 @@ struct MessageBubbleConfiguration { return lookup } + /// Unique lowered `contact.name` → avatar identity. Unlike `buildNicknameLookup`, + /// a nickname is not required. Colliding names are omitted. + nonisolated static func incomingAvatarIdentities( + from contacts: [ContactDTO] + ) -> [String: IncomingAvatarIdentity] { + var counts: [String: Int] = [:] + for contact in contacts { + counts[contact.name.lowercased(), default: 0] += 1 + } + var lookup: [String: IncomingAvatarIdentity] = [:] + for contact in contacts { + let key = contact.name.lowercased() + guard counts[key] == 1 else { continue } + lookup[key] = IncomingAvatarIdentity( + name: contact.name, + matchedContactID: contact.id, + imageRevision: IncomingAvatarIdentity.revision(of: contact.avatarImageData) + ) + } + return lookup + } + /// 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. + /// Baked into `MessageItem.envelope.senderResolution` upstream of the bubble. static func resolveSenderName( for message: MessageDTO, contacts: [ContactDTO], diff --git a/MC1/Views/Chats/Components/MessageBubbleView.swift b/MC1/Views/Chats/Components/MessageBubbleView.swift index bfe5e0ef8..5344f4f65 100644 --- a/MC1/Views/Chats/Components/MessageBubbleView.swift +++ b/MC1/Views/Chats/Components/MessageBubbleView.swift @@ -40,7 +40,8 @@ struct MessageBubbleView: View, Equatable { onMapPreviewTap: { coordinate in actions.onMapPreviewTap(coordinate) }, snapshotResolver: actions.snapshotResolver, requestSnapshot: actions.requestSnapshot, - retrySnapshot: actions.retrySnapshot + retrySnapshot: actions.retrySnapshot, + onTranslationAction: { actions.onTranslationAction(message.id) } ) ) } else { diff --git a/MC1/Views/Chats/Components/MessagePathMapView.swift b/MC1/Views/Chats/Components/MessagePathMapView.swift index e5bb8ce10..838cb8d0a 100644 --- a/MC1/Views/Chats/Components/MessagePathMapView.swift +++ b/MC1/Views/Chats/Components/MessagePathMapView.swift @@ -124,8 +124,13 @@ struct MessagePathMapView: View { Button(L10n.Localizable.Common.done) { dismiss() } } } + .navigationBarTitleDisplayMode(.inline) .onAppear { - locatedNodes = buildLocatedNodes() + rebuildLocatedNodes() + } + .onChange(of: pathViewModel.isLoading) { _, isLoading in + guard !isLoading else { return } + rebuildLocatedNodes() } .onChange(of: isStyleLoaded) { _, loaded in guard loaded, !hasInitiallyFit else { return } @@ -167,13 +172,16 @@ struct MessagePathMapView: View { cameraRegionVersion += 1 } - private func buildLocatedNodes() -> [(point: MapPoint, coordinate: CLLocationCoordinate2D)] { + private func rebuildLocatedNodes() { + locatedNodes = buildLocatedNodes(sender: pathViewModel.locatedSender(for: message)) + } + + private func buildLocatedNodes( + sender: ContactDTO? + ) -> [(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 { + if let sender { let coord = CLLocationCoordinate2D(latitude: sender.latitude, longitude: sender.longitude) nodes.append((MapPoint( id: sender.id, diff --git a/MC1/Views/Chats/Components/MessagePathViewModel.swift b/MC1/Views/Chats/Components/MessagePathViewModel.swift index aab3d619c..42fa4522c 100644 --- a/MC1/Views/Chats/Components/MessagePathViewModel.swift +++ b/MC1/Views/Chats/Components/MessagePathViewModel.swift @@ -70,6 +70,28 @@ final class MessagePathViewModel { return String(format: "%02X", firstByte) } + /// Pin A contact from a non-empty `senderKeyPrefix`, or a unique `senderNodeName` on a channel row. + func locatedSender(for message: MessageDTO) -> ContactDTO? { + if let keyPrefix = message.senderKeyPrefix, !keyPrefix.isEmpty { + guard let sender = contacts.first(where: { $0.publicKeyPrefix == keyPrefix }), + sender.hasLocation else { + return nil + } + return sender + } + + guard message.isChannelMessage, + let senderName = message.senderNodeName, !senderName.isEmpty else { + return nil + } + + let matches = SenderContactMatcher.filter(contacts: contacts, senderName: senderName) + guard matches.count == 1, let sender = matches.first, sender.hasLocation else { + return nil + } + return sender + } + func repeaterResolution(for hashBytes: Data, userLocation: CLLocation?) -> NodeNameResolution { NeighborNameResolver.resolve( for: hashBytes, diff --git a/MC1/Views/Chats/Components/RepeatRowView.swift b/MC1/Views/Chats/Components/RepeatRowView.swift index 38ee22deb..19ba4a348 100644 --- a/MC1/Views/Chats/Components/RepeatRowView.swift +++ b/MC1/Views/Chats/Components/RepeatRowView.swift @@ -1,4 +1,3 @@ -// MC1/Views/Chats/Components/RepeatRowView.swift import CoreLocation import MC1Services import SwiftUI @@ -11,16 +10,19 @@ struct RepeatRowView: View { let userLocation: CLLocation? var body: some View { + let resolution = repeaterResolution HStack(alignment: .top) { - // Left side: Repeater ID + name, hop count VStack(alignment: .leading, spacing: 2) { - HStack { + HStack(spacing: 6) { Text(repeatEntry.repeaterHashFormatted) .font(.body) .foregroundStyle(.secondary) .monospaced() - Text(repeaterName) + Text(resolution.displayName) .font(.body) + if resolution.matchKind == .fallback { + FallbackMatchIndicatorView() + } } Text(hopCountText) @@ -30,7 +32,6 @@ struct RepeatRowView: View { Spacer() - // Right side: Signal bars and metrics VStack(alignment: .trailing, spacing: 2) { Image(systemName: "cellularbars", variableValue: repeatEntry.snrLevel) .foregroundStyle(signalColor) @@ -45,13 +46,47 @@ struct RepeatRowView: View { } } .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(repeaterName)) + // .combine would swallow FallbackMatchIndicatorView; .contain keeps the popover a rotor stop. + .accessibilityElement(children: resolution.isFallback ? .contain : .combine) + .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(resolution.displayName)) .accessibilityValue(L10n.Chats.Chats.Repeats.Row.accessibilityValue(signalQuality, repeatEntry.snrFormatted, repeatEntry.rssiFormatted)) } + /// NeighborNameResolver, not RepeaterResolver.bestMatch: only the former reports `.fallback`. + static func resolution( + repeaterHash: Data?, + repeaters: [ContactDTO], + discoveredRepeaters: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> NodeNameResolution { + guard let repeaterHash else { + return NodeNameResolution( + displayName: L10n.Chats.Chats.Repeats.unknownRepeater, + matchKind: .unresolved + ) + } + return NeighborNameResolver.resolve( + for: repeaterHash, + contacts: repeaters, + discoveredNodes: discoveredRepeaters, + userLocation: userLocation + ) ?? NodeNameResolution( + displayName: L10n.Chats.Chats.Repeats.unknownRepeater, + matchKind: .unresolved + ) + } + // MARK: - Helpers + private var repeaterResolution: NodeNameResolution { + Self.resolution( + repeaterHash: repeatEntry.repeaterHash, + repeaters: repeaters, + discoveredRepeaters: discoveredRepeaters, + userLocation: userLocation + ) + } + private var snrQuality: SNRQuality { repeatEntry.snrQuality } @@ -60,33 +95,14 @@ struct RepeatRowView: View { 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 - } - - 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 - } - - return L10n.Chats.Chats.Repeats.unknownRepeater - } } #Preview { diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index 5076ed1c6..6fda41921 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -97,33 +97,14 @@ struct UnifiedMessageBubble: View, Equatable { item.grouping.showSenderName { SenderNameLabel(resolution: item.envelope.senderResolution, nameColor: senderColor) .senderNamePlacement(enclosingStackSpacing: bubbleStackSpacing) + .padding(.leading, reserveAvatarColumn ? IncomingBubbleAvatarMetrics.columnWidth : 0) } - bubbleActionsLongPress( - BubbleFragmentStack( - item: item, - layout: layout, - bubbleColor: resolvedBubbleColor, - timeColor: footerTimeColor, - 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 - ) - ) + bubbleActionsLongPress(bubbleWithOptionalAvatar) ForEach(Array(layout.siblings.enumerated()), id: \.offset) { _, fragment in siblingFragmentView(fragment) + .padding(.leading, reserveAvatarColumn ? IncomingBubbleAvatarMetrics.columnWidth : 0) } } .accessibilityElement(children: .combine) @@ -132,6 +113,20 @@ struct UnifiedMessageBubble: View, Equatable { callbacks.onLongPress?() } .accessibilityActions { + if let chrome = layout.textPayload?.translation { + switch chrome.phase { + case .offer: + Button(L10n.Chats.Chats.Message.Action.translate) { + callbacks.onTranslationAction?() + } + case .showing: + Button(L10n.Chats.Chats.Message.Action.showOriginal) { + callbacks.onTranslationAction?() + } + case .inProgress: + EmptyView() + } + } if item.footer.showStatusRow, item.footer.status == .failed, let onRetry = callbacks.onRetry { @@ -260,6 +255,48 @@ struct UnifiedMessageBubble: View, Equatable { } } + /// Channel incoming only. DM incoming and every outgoing row skip the column. + private var reserveAvatarColumn: Bool { + configuration.showsIncomingAvatars && !item.envelope.isOutgoing + } + + private var showAvatar: Bool { + reserveAvatarColumn && item.envelope.incomingAvatar != nil + } + + private var bubbleWithOptionalAvatar: some View { + IncomingAvatarGutter( + identity: showAvatar ? item.envelope.incomingAvatar : nil, + reserveColumn: reserveAvatarColumn, + messageID: message.id + ) { + stackedBubble + } + } + + private var stackedBubble: some View { + BubbleFragmentStack( + item: item, + layout: layout, + bubbleColor: resolvedBubbleColor, + timeColor: footerTimeColor, + 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 + ) + } + // MARK: - Computed Properties private var resolvedBubbleColor: Color { @@ -313,7 +350,14 @@ struct UnifiedMessageBubble: View, Equatable { } } } - label += message.text + switch layout.textPayload?.translation?.phase { + case let .showing(translated, _): + label += L10n.Chats.Chats.Message.translatedAccessibility(translated) + case .inProgress: + label += "\(message.text), \(L10n.Chats.Chats.Message.Action.translating)" + default: + label += message.text + } if item.envelope.isOutgoing { label += ", \(MessageStatusText.text(for: item.footer))" } @@ -325,7 +369,7 @@ struct UnifiedMessageBubble: View, Equatable { label += ", \(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath))" } if let region = item.footer.regionToShow { - label += ", \(L10n.Chats.Chats.Message.Region.accessibilityLabel(region))" + label += ", \(MessageRegionAccessibility.label(regionName: region, matchNames: item.footer.regionMatchNames))" } } return label diff --git a/MC1/Views/Chats/ContactMatchRow.swift b/MC1/Views/Chats/ContactMatchRow.swift index b04a12a75..c77abfab7 100644 --- a/MC1/Views/Chats/ContactMatchRow.swift +++ b/MC1/Views/Chats/ContactMatchRow.swift @@ -27,6 +27,9 @@ struct ContactMatchRow: View { .bold() .foregroundStyle(.primary) + // On-air last advert only. `recencyTimestamp` includes lastModified + // (path updates, favorite toggles) and would claim a freshness the + // node did not earn when the user is picking among name matches. RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) HStack(spacing: 4) { diff --git a/MC1/Views/Chats/ConversationListContent.swift b/MC1/Views/Chats/ConversationListContent.swift index 66724dd57..407f36c75 100644 --- a/MC1/Views/Chats/ConversationListContent.swift +++ b/MC1/Views/Chats/ConversationListContent.swift @@ -228,7 +228,7 @@ private struct ConversationRowLabel: View { case let .channel(channel): ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) case let .room(session): - RoomConversationRow(session: session, referenceDate: referenceDate) + RoomConversationRow(session: session, viewModel: viewModel, referenceDate: referenceDate) } } .padding(.horizontal, ConversationRowLayout.horizontalPadding) diff --git a/MC1/Views/Chats/ConversationRow.swift b/MC1/Views/Chats/ConversationRow.swift index 028e51324..76b520519 100644 --- a/MC1/Views/Chats/ConversationRow.swift +++ b/MC1/Views/Chats/ConversationRow.swift @@ -43,6 +43,10 @@ struct ConversationRow: View { Spacer() + if viewModel.conversationHasFailedSend(contact.id) { + FailedSendIndicator() + } + UnreadBadges( unreadCount: contact.unreadCount, unreadMentionCount: contact.unreadMentionCount, diff --git a/MC1/Views/Chats/FailedSendIndicator.swift b/MC1/Views/Chats/FailedSendIndicator.swift new file mode 100644 index 000000000..ea8363901 --- /dev/null +++ b/MC1/Views/Chats/FailedSendIndicator.swift @@ -0,0 +1,187 @@ +import MC1Services +import SwiftUI + +/// Presence-only badge for a conversation with at least one outgoing failed send. +/// Solid fill — not Liquid Glass — so it stays readable under outdoor glare. +struct FailedSendIndicator: View { + private enum Metrics { + static let size: CGFloat = 18 + static let systemImage = "arrow.clockwise" + } + + var body: some View { + Image(systemName: Metrics.systemImage) + .font(.caption.bold()) + .foregroundStyle(.white) + .frame(width: Metrics.size, height: Metrics.size) + .background(Color.red, in: .circle) + .accessibilityLabel(L10n.Chats.Chats.Row.failedSend) + } +} + +#Preview("Badge") { + FailedSendIndicator() + .padding() +} + +#Preview("Conversation list") { + FailedSendIndicatorPreviewList() +} + +/// Fixture list for canvas / simulator preview of the failed-send row badge. +private struct FailedSendIndicatorPreviewList: View { + @State private var viewModel: ChatViewModel + private let alice: ContactDTO + private let bob: ContactDTO + private let channel: ChannelDTO + private let room: RemoteNodeSessionDTO + private let now: Date + + init() { + let now = Date() + let radioID = UUID() + let alice = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x11, count: 32), + name: "Alice Chen", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 2, + outPath: Data([0x10, 0x20]), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-120), + unreadCount: 2 + ) + let bob = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x22, count: 32), + name: "Bob Martinez", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 1, + outPath: Data([0x20]), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: true, + lastMessageDate: now.addingTimeInterval(-900), + unreadCount: 0 + ) + let channel = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 0, + name: "Mesh HQ", + secret: Data(), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-60), + unreadCount: 4, + unreadMentionCount: 1, + notificationLevel: .all, + isFavorite: false + ) + let room = RemoteNodeSessionDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x33, count: 32), + name: "Summit Room", + role: .roomServer, + isConnected: false, + unreadCount: 1, + lastMessageDate: now.addingTimeInterval(-3600) + ) + let viewModel = ChatViewModel() + viewModel.failedSendConversationIDs = [alice.id, channel.id, room.id] + viewModel.lastMessageCache = [ + alice.id: Self.previewMessage( + radioID: radioID, + contactID: alice.id, + text: "Need extra batteries at the aid station", + status: .failed, + now: now + ), + bob.id: Self.previewMessage( + radioID: radioID, + contactID: bob.id, + text: "Copy, staging at the trailhead", + status: .delivered, + now: now + ), + channel.id: Self.previewMessage( + radioID: radioID, + channelIndex: channel.index, + text: "Net check — anyone copy?", + status: .failed, + now: now + ) + ] + self.now = now + self.alice = alice + self.bob = bob + self.channel = channel + self.room = room + _viewModel = State(initialValue: viewModel) + } + + var body: some View { + NavigationStack { + List { + ConversationRow(contact: alice, viewModel: viewModel, referenceDate: now) + ConversationRow(contact: bob, viewModel: viewModel, referenceDate: now) + ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: now) + RoomConversationRow(session: room, viewModel: viewModel, referenceDate: now) + } + .listStyle(.plain) + .navigationTitle(L10n.Chats.Chats.title) + } + .environment(\.appState, AppState()) + } + + private static func previewMessage( + radioID: UUID, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + text: String, + status: MessageStatus, + now: Date + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: UInt32(now.timeIntervalSince1970), + createdAt: now, + direction: .outgoing, + status: status, + textType: .plain, + ackCode: nil, + pathLength: 1, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: true, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: status == .failed ? 3 : 0, + maxRetryAttempts: 3 + ) + } +} diff --git a/MC1/Views/Chats/Reactions/MessageAction.swift b/MC1/Views/Chats/Reactions/MessageAction.swift index ff25e5ac1..1d1b800e8 100644 --- a/MC1/Views/Chats/Reactions/MessageAction.swift +++ b/MC1/Views/Chats/Reactions/MessageAction.swift @@ -4,6 +4,7 @@ enum MessageAction: Equatable { case react(String) case reply case copy + case translate case sendAgain case sendDM case blockSender diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift index c27e86a1a..3f26628ec 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift @@ -32,6 +32,12 @@ struct ActionsButtonsSection: View { action: { onSelectAction(.copy) } ) + ActionButton( + title: L10n.Chats.Chats.Message.Action.translate, + icon: "translate", + action: { onSelectAction(.translate) } + ) + if availability.canSendAgain { ActionButton( title: L10n.Chats.Chats.Message.Action.sendAgain, diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift index e722eb19a..1eede52f3 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift @@ -190,11 +190,21 @@ private struct ActionsIncomingDetailsRows: View { } 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 regionText: String = { + switch RegionScopeSemantics.coalesce( + scope: message.regionScope, + matches: message.regionScopeMatches + ) { + case .none: + return L10n.Chats.Chats.Message.Info.regionUnresolved + case let .unique(name): + return L10n.Chats.Chats.Message.Info.floodedUnder(name) + case let .ambiguous(names): + let list = ListFormatter.localizedString(byJoining: names) + return L10n.Chats.Chats.Message.Info.regionAmbiguous(list) + } + }() + ActionInfoRow(text: regionText, icon: "globe") } let sentText = L10n.Chats.Chats.Message.Info.sent( diff --git a/MC1/Views/Chats/Room/RoomConversationRow.swift b/MC1/Views/Chats/Room/RoomConversationRow.swift index a5618449b..ecffa1bf6 100644 --- a/MC1/Views/Chats/Room/RoomConversationRow.swift +++ b/MC1/Views/Chats/Room/RoomConversationRow.swift @@ -4,6 +4,7 @@ import SwiftUI struct RoomConversationRow: View { @Environment(\.appState) private var appState let session: RemoteNodeSessionDTO + let viewModel: ChatViewModel var referenceDate: Date? var body: some View { @@ -45,6 +46,10 @@ struct RoomConversationRow: View { Spacer() + if viewModel.conversationHasFailedSend(session.id) { + FailedSendIndicator() + } + UnreadBadges( unreadCount: session.unreadCount, notificationLevel: session.notificationLevel diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift b/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift index 6642d7273..fcd9409b8 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift @@ -22,17 +22,16 @@ extension ChatTimeline { /// message state. No-ops when the message is no longer present. func rebakeRow(_ messageID: UUID) { guard let coordinator, let writer else { return } - guard let message = coordinator.messagesByID[messageID] else { + let messages = coordinator.messages + guard let index = messages.firstIndex(where: { $0.id == messageID }) else { logger.warning("rebake requested for missing message id \(messageID)") return } - let previous: MessageDTO? = { - guard let index = coordinator.messages.firstIndex(where: { $0.id == messageID }), - index > 0 else { return nil } - return coordinator.messages[index - 1] - }() + let message = messages[index] + let previous: MessageDTO? = index > 0 ? messages[index - 1] : nil + let next: MessageDTO? = index + 1 < messages.count ? messages[index + 1] : nil writer.updateRenderItem(id: messageID) { _ in - makeItem(for: message, previous: previous) + makeItem(for: message, previous: previous, next: next) } } @@ -40,12 +39,13 @@ extension ChatTimeline { /// and decoded-cache rehydration run synchronously inside /// `makeBuildInputs`, so the returned item already carries its preview /// fragment at a stable height. - func makeItem(for message: MessageDTO, previous: MessageDTO?) -> MessageItem { + func makeItem(for message: MessageDTO, previous: MessageDTO?, next: MessageDTO?) -> MessageItem { MessageFragmentBuilder.makeItem( for: message, inputs: bake.makeBuildInputs( for: message, previous: previous, + next: next, envInputs: envInputs, senderTables: senderTablesProvider() ), diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift b/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift index 5a90787da..a02e53fe8 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift @@ -95,6 +95,9 @@ extension ChatTimeline { bake.decodedPreviewAssets.removeAll() bake.legacyPreviewDecodeInFlight.removeAll() bake.cachedURLs.removeAll() + bake.detectedLanguages.removeAll() + bake.translationPhases.removeAll() + bake.translationCache.removeAll() bake.imageURLsServingPages.removeAll() bake.mapPreviewRequestIndex.removeAll() bake.loadedImageData.removeAllObjects() @@ -107,6 +110,9 @@ extension ChatTimeline { /// a row that no longer exists. func removeBakeState(for messageID: UUID) { bake.previewStates.removeValue(forKey: messageID) + bake.detectedLanguages.removeValue(forKey: messageID) + bake.translationPhases.removeValue(forKey: messageID) + bake.translationCache.removeValue(forKey: messageID) bake.loadedPreviews.removeValue(forKey: messageID) bake.decodedPreviewAssets.removeValue(forKey: messageID) bake.loadedImageData.removeObject(forKey: messageID as NSUUID) diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift index 55c6202ca..ed343ef10 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift @@ -4,12 +4,13 @@ import MC1Services extension ChatTimeline { // MARK: - Populate - /// Populates the coordinator with the first page for `conversation` and - /// bakes render items, via the shared fetch → divider → filter → write → - /// bake sequence. Returns `.unavailable` when unbound. + /// Populates the coordinator for `conversation` and bakes render items. + /// Returns `.unavailable` when unbound. + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. func open( _ conversation: ChatConversationType, - reactions: ReactionIndexing? + reactions: ReactionIndexing?, + populateMode: ChatPopulateMode ) async -> ChatTimelinePopulator.Outcome { self.conversation = conversation // Every outcome settles: a failed or unavailable open has no divider @@ -22,6 +23,14 @@ extension ChatTimeline { if role == .interactive, openUnreadCount == 0 { bake.dividerComputed = true } + #if DEBUG + if let error = testPopulateError { + writer.beginLoading() + writer.markLoaded() + return .failed(error) + } + coordinator?.testPopulateFetchError = testPopulateFetchError + #endif let context = reactions.map { indexing in ChatTimelinePopulator.ReactionIndexingContext( reactionService: indexing.service, @@ -31,16 +40,49 @@ extension ChatTimeline { } ) } - return await ChatTimelinePopulator.populate( - conversation, - writer: writer, - dataStore: dataStoreProvider(), - bake: bake, - envInputs: envInputs, - senderTables: senderTablesProvider(), - reactions: context, - postApply: postApply - ) + guard let coordinator else { return .unavailable } + let outcome: ChatTimelinePopulator.Outcome + do { + outcome = try await writer.performWindowOperation { + try Task.checkCancellation() + // Compute the anchor inside the lane. An earlier loadOlder would + // extend the window; an enqueue-time anchor would truncate it. + let refreshWindow = populateMode == .refreshWindow + && coordinator.conversationID == conversation.coordinatorID + && coordinator.renderState.phase == .loaded + && !coordinator.messages.isEmpty + let anchorSortDate: Date? = refreshWindow + ? coordinator.messages.map(\.sortDate).min() + : nil + return await ChatTimelinePopulator.populate( + conversation, + writer: writer, + dataStore: dataStoreProvider(), + bake: bake, + envInputs: envInputs, + senderTables: senderTablesProvider, + postApply: postApply, + anchorSortDate: anchorSortDate + ) + } + } catch is CancellationError { + return .cancelled + } catch { + return .failed(error) + } + if case .loaded = outcome, + let context, + let dataStore = dataStoreProvider() { + await ChatTimelinePopulator.indexMessagesForReactions( + coordinator.messages, + scope: context.scope, + reactionService: context.reactionService, + dataStore: dataStore, + writer: writer, + rebakeRow: context.rebakeRow + ) + } + return outcome } // MARK: - Paging @@ -49,91 +91,152 @@ extension ChatTimeline { /// rebakes. Returns the newly loaded messages (reaction-filtered and /// deduplicated) for caller-side bookkeeping such as sender registration /// and reaction indexing; empty when skipped (already loading, end of - /// history, unbound). Throws the fetch error after retiring the spinner. + /// history, or unbound). Fetch errors throw after the spinner retires. @discardableResult func loadOlder() async throws -> [MessageDTO] { - guard !renderState.isLoadingOlder, renderState.hasMoreMessages else { return [] } - guard let writer, let conversation, let dataStore = dataStoreProvider() else { return [] } + guard let coordinator, let writer, let conversation else { return [] } + guard let dataStore = dataStoreProvider() else { return [] } + do { + return try await writer.performWindowOperation { + try Task.checkCancellation() + guard !coordinator.renderState.isLoadingOlder, + coordinator.renderState.hasMoreMessages else { return [] } - writer.updateRenderState { $0.with(isLoadingOlder: true) } + writer.updateRenderState { $0.with(isLoadingOlder: true) } - do { - let currentOffset = renderState.totalFetchedCount - var olderMessages: [MessageDTO] - let isDM: Bool - - switch conversation { - case let .dm(contact): - isDM = true - olderMessages = try await dataStore.fetchMessages( - contactID: contact.id, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - case let .channel(channel): - isDM = false - olderMessages = try await dataStore.fetchMessages( - radioID: channel.radioID, - channelIndex: channel.index, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - } + do { + let currentOffset = coordinator.renderState.totalFetchedCount + var olderMessages: [MessageDTO] + let isDM: Bool - // Offsets count unfiltered rows, so end-of-history and the next - // page's offset both derive from the raw fetch count. - let unfilteredCount = olderMessages.count - writer.updateRenderState { current in - current.with( - hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, - totalFetchedCount: current.totalFetchedCount + unfilteredCount - ) - } + switch conversation { + case let .dm(contact): + isDM = true + olderMessages = try await dataStore.fetchMessages( + contactID: contact.id, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + case let .channel(channel): + isDM = false + olderMessages = try await dataStore.fetchMessages( + radioID: channel.radioID, + channelIndex: channel.index, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + } + + #if DEBUG + await loadOlderInterleaveHook?() + if let error = loadOlderTestError { + throw error + } + #endif + + try Task.checkCancellation() + + // Offsets count unfiltered rows, so end-of-history and the next + // page's offset both derive from the raw fetch count. + let unfilteredCount = olderMessages.count + writer.updateRenderState { current in + current.with( + hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, + totalFetchedCount: current.totalFetchedCount + unfilteredCount + ) + } + + olderMessages = bake.filterOutgoingReactionMessages(olderMessages, isDM: isDM) + + // An in-flight admission can land a message this fetch also carries; + // drop rows already present so the prepend cannot duplicate them. + let existingIDs = Set(coordinator.messages.map(\.id)) + olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } - olderMessages = bake.filterOutgoingReactionMessages(olderMessages, isDM: isDM) - - // An in-flight admission can land a message this fetch also carries; - // drop rows already present so the prepend cannot duplicate them. - let existingIDs = Set(messages.map(\.id)) - olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } - - // Prepend older messages (they're chronologically earlier), then - // re-run same-sender reordering across the page boundary to handle - // clusters that were split between the existing and newly loaded pages. - writer.prepend(olderMessages) - let reordered = MessageDTO.reorderSameSenderClusters(messages) - writer.replaceMessagesPreservingByID(reordered) - - // Clear the spinner before rebaking, not after. `updateRenderState` - // bumps the coordinator's `renderStateID`; doing it after the rebake - // invalidates the just-launched off-main build on apply, forcing a full - // duplicate rebuild of the entire timeline. The prepended messages are - // already in the canonical array, so the spinner can retire now, and - // slower caller-side follow-up (reaction indexing) never gates it. - writer.updateRenderState { $0.with(isLoadingOlder: false) } - - rebakeAll() - return olderMessages + // Re-run same-sender reordering so clusters split across the + // page boundary stay grouped. + writer.prepend(olderMessages) + let reordered = MessageDTO.reorderSameSenderClusters(coordinator.messages) + writer.replaceMessagesPreservingByID(reordered) + + // Retire the spinner before rebake. `updateRenderState` bumps + // `renderStateID`; doing it after would invalidate the just-launched build. + writer.updateRenderState { $0.with(isLoadingOlder: false) } + + bake.bakeAll( + messages: coordinator.messages, + writer: writer, + envInputs: envInputs, + senderTables: senderTablesProvider(), + postApply: postApply + ) + return olderMessages + } catch is CancellationError { + writer.updateRenderState { $0.with(isLoadingOlder: false) } + return [] + } catch { + writer.updateRenderState { $0.with(isLoadingOlder: false) } + throw error + } + } + } catch is CancellationError { + return [] } catch { - writer.updateRenderState { $0.with(isLoadingOlder: false) } throw error } } // MARK: - Admission + /// Result of `admit`: whether the row was inserted, and the same-sender + /// cluster-end pair when the previous last incoming avatar should drop onto + /// the new tail. + struct Admission: Equatable { + struct Handoff: Equatable, Sendable { + let fromID: UUID + let toID: UUID + let identity: IncomingAvatarIdentity + } + + let inserted: Bool + let handoff: Handoff? + + static let ignored = Admission(inserted: false, handoff: nil) + } + /// Admits a message into the open timeline: dedupes against the loaded /// window and appends the message and its baked render item in one call /// frame, so the row lands already carrying its preview fragment. Returns - /// false when the message was already present or the timeline is unbound + /// `.ignored` when the message was already present or the timeline is unbound /// (a stale writer drops the append at the coordinator). @discardableResult - func admit(_ message: MessageDTO) -> Bool { - guard coordinator != nil, let writer else { return false } + func admit(_ message: MessageDTO) -> Admission { + guard coordinator != nil, let writer else { return .ignored } let previous = messages.last - guard writer.append(message) else { return false } - writer.appendRenderItem(makeItem(for: message, previous: previous)) - return true + guard writer.append(message) else { return .ignored } + let newItem = makeItem(for: message, previous: previous, next: nil) + let shouldHandoff = previous.map { + ChatMessageBakeState.incomingClusterContinues(from: $0, to: message) + } ?? false + writer.updateRenderState { state in + var next = state + if shouldHandoff, let previous { + next = next.updatingItem(id: previous.id) { item in + item.with(envelope: item.envelope.with(incomingAvatar: nil)) + } + } + return next.appendingItem(newItem) + } + let handoff: Admission.Handoff? = if shouldHandoff, let previous, let identity = newItem.envelope.incomingAvatar { + Admission.Handoff( + fromID: previous.id, + toID: message.id, + identity: identity + ) + } else { + nil + } + return Admission(inserted: true, handoff: handoff) } // MARK: - Message mutations @@ -160,10 +263,34 @@ extension ChatTimeline { writer?.enqueueReload(messageID: messageID) } - /// Removes a message and its render item together. + func enqueueReload(updatedMessageIDs: Set) { + writer?.enqueueReload(updatedMessageIDs: updatedMessageIDs) + } + + /// Removes a message and rebakes remaining channel neighbors in one items update. func removeMessage(_ messageID: UUID) { - writer?.remove(messageID: messageID) - writer?.removeRenderItem(id: messageID) + guard let writer, let coordinator else { return } + let messages = coordinator.messages + guard let index = messages.firstIndex(where: { $0.id == messageID }) else { return } + let previous = index > 0 ? messages[index - 1] : nil + let nextDTO = index + 1 < messages.count ? messages[index + 1] : nil + let prevPrev = index > 1 ? messages[index - 2] : nil + let nextNext = index + 2 < messages.count ? messages[index + 2] : nil + writer.remove(messageID: messageID) + writer.updateRenderState { state in + var next = state.removingItem(id: messageID) + if let previous, previous.isChannelMessage { + next = next.updatingItem(id: previous.id) { _ in + makeItem(for: previous, previous: prevPrev, next: nextDTO) + } + } + if let nextDTO, nextDTO.isChannelMessage { + next = next.updatingItem(id: nextDTO.id) { _ in + makeItem(for: nextDTO, previous: previous, next: nextNext) + } + } + return next + } } /// Updates a loaded message in place and rebakes its row. No-ops when diff --git a/MC1/Views/Chats/Timeline/ChatTimeline.swift b/MC1/Views/Chats/Timeline/ChatTimeline.swift index ce6e4f79c..a454c59f7 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline.swift @@ -66,7 +66,22 @@ final class ChatTimeline { /// `stageOpen` and `open`. var initialLoadSettled = false - /// Live per-connection store; nil while disconnected (offline browse). + #if DEBUG + /// When set, `open` returns populate's failure contract without a process-global hook. + var testPopulateError: Error? + + /// Awaited in `loadOlder` after the fetch so a test can queue populate behind it. + @ObservationIgnored + var loadOlderInterleaveHook: (@MainActor () async -> Void)? + + /// When set, `loadOlder` throws this after the interleave hook. + var loadOlderTestError: Error? + + /// When set, populate throws this after the entry spinner clear. + var testPopulateFetchError: Error? + #endif + + /// Process-lifetime store when a radio has been paired. Nil until first pair. @ObservationIgnored var dataStoreProvider: @MainActor () -> DataStore? = { nil } diff --git a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift index 973222c22..bde80b4cd 100644 --- a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift @@ -20,13 +20,39 @@ extension ChatMessageBakeState { let showDirectionGap: Bool let showSenderName: Bool let showDayDivider: Bool + let isClusterEnd: Bool + } + + /// Channel-incoming only. Nil names break; empty string vs empty string continues. + static func incomingClusterContinues(from earlier: MessageDTO, to later: MessageDTO) -> Bool { + guard earlier.isChannelMessage, later.isChannelMessage, + !earlier.isOutgoing, !later.isOutgoing else { return false } + let timeGap = abs(Int(later.senderDate.timeIntervalSince(earlier.senderDate))) + guard timeGap <= messageGroupingGapSeconds else { return false } + guard let currentName = later.senderNodeName, + let previousName = earlier.senderNodeName else { return false } + return currentName == previousName } /// Computes all display flags in a single pass to avoid redundant message lookups. /// Used by `bakeAll` for O(n) performance instead of O(3n). - static func computeDisplayFlags(for message: MessageDTO, previous: MessageDTO?) -> DisplayFlags { + static func computeDisplayFlags( + for message: MessageDTO, + previous: MessageDTO?, + next: MessageDTO? + ) -> DisplayFlags { + let continuesToNext = next.map { incomingClusterContinues(from: message, to: $0) } ?? false + let isClusterEnd = + message.isChannelMessage && !message.isOutgoing && !continuesToNext + guard let previous else { - return DisplayFlags(showTimestamp: true, showDirectionGap: false, showSenderName: true, showDayDivider: true) + return DisplayFlags( + showTimestamp: true, + showDirectionGap: false, + showSenderName: true, + showDayDivider: true, + isClusterEnd: isClusterEnd + ) } // Keys on send time (senderDate), not the sortDate sort key. Under block-at-reconnect @@ -60,7 +86,13 @@ extension ChatMessageBakeState { true } - return DisplayFlags(showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, showDayDivider: dayChanged) + return DisplayFlags( + showTimestamp: showTimestamp, + showDirectionGap: showDirectionGap, + showSenderName: showSenderName, + showDayDivider: dayChanged, + isClusterEnd: isClusterEnd + ) } // MARK: - Item Build @@ -76,11 +108,13 @@ extension ChatMessageBakeState { func makeBuildInputs( for message: MessageDTO, previous: MessageDTO?, + next: MessageDTO?, envInputs: EnvInputs, senderTables: ChatSenderTables ) -> MessageBuildInputs { seedPreviewStateIfNeeded(for: message, envInputs: envInputs) - let flags = Self.computeDisplayFlags(for: message, previous: previous) + seedDetectedLanguageIfNeeded(for: message) + let flags = Self.computeDisplayFlags(for: message, previous: previous, next: next) let cachedURL = cachedURLs[message.id].flatMap(\.self) // Extension-based image classification, minus URLs the fetch path has // since discovered serve an HTML page. Computed once and reused for the @@ -138,6 +172,17 @@ extension ChatMessageBakeState { isMapPreviewReady = MapSnapshotStore.shared.isResolved(request) } + let senderResolution = senderResolutionFor(message, senderTables: senderTables) + let incomingAvatar: IncomingAvatarIdentity? = if flags.isClusterEnd { + IncomingAvatarIdentity.resolve( + senderNodeName: message.senderNodeName, + displayName: senderResolution.displayName, + table: senderTables.incomingAvatars + ) + } else { + nil + } + return MessageBuildInputs( messageID: message.id, previewState: previewStates[message.id] ?? .idle, @@ -158,15 +203,31 @@ extension ChatMessageBakeState { formattedPath: (envInputs.showIncomingPath && !message.isOutgoing) ? MessagePathFormatter.format(message) : nil, - senderResolution: senderResolutionFor(message, senderTables: senderTables), + senderResolution: senderResolution, showTimestamp: flags.showTimestamp, showDirectionGap: flags.showDirectionGap, showSenderName: flags.showSenderName, showNewMessagesDivider: message.id == newMessagesDividerMessageID, - showDayDivider: flags.showDayDivider + showDayDivider: flags.showDayDivider, + incomingAvatar: incomingAvatar, + translation: message.isOutgoing + ? nil + : MessageTranslationChrome.resolved( + detected: detectedLanguages[message.id], + phase: translationPhases[message.id], + preferredLanguageCode: envInputs.preferredLanguageCode + ) ) } + /// Writes a missing detection key. Stores `.undetermined` so a locale + /// change does not re-run the recognizer. + func seedDetectedLanguageIfNeeded(for message: MessageDTO) { + guard !message.isOutgoing else { return } + guard detectedLanguages[message.id] == nil else { return } + detectedLanguages[message.id] = MessageLanguageDetector.dominantLanguage(for: message.text) + } + /// Whether `url` routes to the inline-image fragment and fetch path: an /// image-extension or resolvable URL the fetch path has not since found to /// serve an HTML page. `imageURLsServingPages` covers reroutes discovered @@ -244,19 +305,8 @@ extension ChatMessageBakeState { messages.filter { !isHiddenOutgoingReaction($0, isDM: isDM) } } - /// 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 } - - let isReaction = isDM - ? ReactionParser.parseDM(message.text) != nil - : ReactionParser.parse(message.text) != nil - - guard isReaction else { return false } - - return message.status != .failed + message.isHiddenOutgoingReaction(isDM: isDM) } // MARK: - Batch Bake @@ -293,11 +343,13 @@ extension ChatMessageBakeState { // this loop already carrying its preview fragment at a stable height. let inputs: [(MessageDTO, MessageBuildInputs)] = messages.enumerated().map { index, message in let previous: MessageDTO? = index > 0 ? messages[index - 1] : nil + let next: MessageDTO? = index + 1 < messages.count ? messages[index + 1] : nil return ( message, makeBuildInputs( for: message, previous: previous, + next: next, envInputs: envInputs, senderTables: senderTables ) diff --git a/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift b/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift index 64c55ca7a..95fed3e14 100644 --- a/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift +++ b/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift @@ -49,6 +49,18 @@ final class ChatMessageBakeState { /// Cached URL detection results to avoid re-running NSDataDetector on rebuilds var cachedURLs: [UUID: URL?] = [:] + /// Detected language keyed by message id. Missing key = not yet run; + /// `.undetermined` = ran, no code. + var detectedLanguages: [UUID: DetectedLanguage] = [:] + + /// Per-message Translation offer phase. Missing means bake decides from + /// detection (typically `.offer` or no row). + var translationPhases: [UUID: MessageTranslationChrome.Phase] = [:] + + /// Last successful translation per message, keyed by collapsed target + /// language code. A DE→EN result must not be reused as DE→FR. + var translationCache: [UUID: [String: String]] = [:] + /// Image-extension URLs the fetch path has discovered serve an HTML page, /// not image bytes (imgur, pasteboard, prnt.sc). Keyed by URL string so one /// discovery reroutes every loaded message sharing it. Gates the synchronous diff --git a/MC1/Views/Chats/ViewModel/ChatSenderTables.swift b/MC1/Views/Chats/ViewModel/ChatSenderTables.swift index 96f457d76..a7d93fbd6 100644 --- a/MC1/Views/Chats/ViewModel/ChatSenderTables.swift +++ b/MC1/Views/Chats/ViewModel/ChatSenderTables.swift @@ -6,6 +6,12 @@ import MC1Services struct ChatSenderTables: Equatable { let contacts: [ContactDTO] let nicknamesByLoweredName: [String: String] + /// Unique lowered contact name → channel incoming-avatar identity. + let incomingAvatars: [String: IncomingAvatarIdentity] - static let empty = ChatSenderTables(contacts: [], nicknamesByLoweredName: [:]) + static let empty = ChatSenderTables( + contacts: [], + nicknamesByLoweredName: [:], + incomingAvatars: [:] + ) } diff --git a/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift b/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift index b693e8671..3197cc922 100644 --- a/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift +++ b/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift @@ -23,18 +23,18 @@ enum ChatTimelinePopulator { let rebakeRow: @MainActor (UUID) -> Void } - /// Populates `writer`'s coordinator with the first page for `conversation` - /// and bakes render items. Owns the loading bracket (`beginLoading` / - /// `markLoaded` on every exit). + /// Populates `writer`'s coordinator and bakes render items. Owns the + /// loading bracket. `anchorSortDate` is nil for a first-page open; a + /// refresh passes the oldest loaded `sortDate` so paged-in history stays. static func populate( _ conversation: ChatConversationType, writer: ChatTimelineWriter, dataStore: DataStore?, bake: ChatMessageBakeState, envInputs: EnvInputs, - senderTables: ChatSenderTables, - reactions: ReactionIndexingContext?, - postApply: (@MainActor () -> Void)? + senderTables: @MainActor () -> ChatSenderTables, + postApply: (@MainActor () -> Void)?, + anchorSortDate: Date? ) async -> Outcome { writer.beginLoading() @@ -43,63 +43,68 @@ enum ChatTimelinePopulator { return .unavailable } - // Reset pagination state for the new conversation page. - writer.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + // Clear a stuck prepend spinner from a loadOlder that raced a disconnect. + // Leave the loaded window intact: the next refresh reads `min(sortDate)` from it. + writer.updateRenderState { $0.with(isLoadingOlder: false) } do { + #if DEBUG + if let error = writer.testPopulateFetchError { + throw error + } + #endif let unreadCount = await currentUnreadCount(for: conversation, dataStore: dataStore) let isDM: Bool - let initialLimit = ChatCoordinator.initialPageSize(unreadCount: unreadCount) - var fetchedMessages: [MessageDTO] + let floorLimit = ChatCoordinator.initialPageSize(unreadCount: unreadCount) + let window: (messages: [MessageDTO], hasMore: Bool) switch conversation { case let .dm(contact): isDM = true - fetchedMessages = try await dataStore.fetchMessages( + window = try await dataStore.fetchMessageWindow( contactID: contact.id, - limit: initialLimit, - offset: 0 + anchorSortDate: anchorSortDate, + floorLimit: floorLimit ) case let .channel(channel): isDM = false - fetchedMessages = try await dataStore.fetchMessages( + window = try await dataStore.fetchMessageWindow( radioID: channel.radioID, channelIndex: channel.index, - limit: initialLimit, - offset: 0 + anchorSortDate: anchorSortDate, + floorLimit: floorLimit ) } + #if DEBUG + await writer.testPopulateAfterFetchHook?() + #endif + try Task.checkCancellation() + + var fetchedMessages = window.messages let unfilteredCount = fetchedMessages.count - writer.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } // Divider from the unfiltered fetch so a hidden outgoing reaction at // the boundary still places the line at the correct visual index. bake.computeDividerPosition(from: fetchedMessages, unreadCount: unreadCount, isDM: isDM) fetchedMessages = bake.filterOutgoingReactionMessages(fetchedMessages, isDM: isDM) - writer.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == initialLimit) } + writer.updateRenderState { + $0.with( + hasMoreMessages: window.hasMore, + totalFetchedCount: unfilteredCount + ) + } writer.replaceAll(fetchedMessages) bake.bakeAll( messages: fetchedMessages, writer: writer, envInputs: envInputs, - senderTables: senderTables, + senderTables: senderTables(), postApply: postApply ) - if let reactions { - await indexMessagesForReactions( - fetchedMessages, - scope: reactions.scope, - reactionService: reactions.reactionService, - dataStore: dataStore, - writer: writer, - rebakeRow: reactions.rebakeRow - ) - } - writer.markLoaded() return .loaded } catch is CancellationError { diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift index ead76686f..b5f12b478 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift @@ -8,14 +8,15 @@ extension ChatViewModel { /// populates the coordinator, then clears unread state. Delegates coordinator /// population to `primeInitialChannelMessages(for:)`; the unread/badge/notify /// side effects here run only when that load succeeded. - func loadChannelMessages(for channel: ChannelDTO) async { + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. + func loadChannelMessages(for channel: ChannelDTO, populateMode: ChatPopulateMode) async { // Track active channel for notification suppression notificationService?.setActiveConversation( channelIndex: channel.index, channelRadioID: channel.radioID ) - let loaded = await primeInitialChannelMessages(for: channel) + let loaded = await primeInitialChannelMessages(for: channel, populateMode: populateMode) // Push the device flood scope after populating the timeline. A device // command, so it runs only on real channel open — never during prefetch. @@ -29,6 +30,7 @@ extension ChatViewModel { do { try await dataStore?.clearChannelUnreadCount(channelID: channel.id) try await dataStore?.clearChannelUnreadMentionCount(channelID: channel.id) + try await dataStore?.markFailedSendsSeen(radioID: channel.radioID, channelIndex: channel.index) } catch { logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") } @@ -38,15 +40,15 @@ extension ChatViewModel { await notificationService?.updateBadgeCount() } - /// Populates the bound coordinator with the first page for `channel` and builds - /// its render items and mention senders — with no notification, flood-scope, + /// Populates the bound coordinator for `channel` and builds its render + /// items and mention senders — with no notification, flood-scope, /// unread-clearing, or badge side effects. Safe to run before navigation to /// warm the coordinator so the channel renders populated on the first frame /// instead of popping in after the push transition. `loadChannelMessages` - /// layers the open-time side effects on top. Returns true when the fetch - /// succeeded. + /// layers the open-time side effects on top. + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. @discardableResult - func primeInitialChannelMessages(for channel: ChannelDTO) async -> Bool { + func primeInitialChannelMessages(for channel: ChannelDTO, populateMode: ChatPopulateMode) async -> Bool { // Clear preview state only when switching away from a previously loaded // conversation. A fresh view model has nothing to clear, and its cells // may already be fetching previews for this same conversation (warm @@ -76,7 +78,11 @@ extension ChatViewModel { ) } - let outcome = await timeline.open(.channel(channel), reactions: reactions) + let outcome = await timeline.open( + .channel(channel), + reactions: reactions, + populateMode: populateMode + ) let didLoad: Bool switch outcome { @@ -165,10 +171,7 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } @@ -228,10 +231,7 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } @@ -309,6 +309,7 @@ extension ChatViewModel { latitude: 0.0, longitude: 0.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift index 633fec79a..7146bdd3c 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift @@ -182,6 +182,9 @@ extension ChatViewModel { channelSenderOrder = [:] contactNameSet = [] lastMessageCache = [:] + // Invalidates in-flight indicator refreshes; the list event task is not `reloadTask`. + failedSendRefreshGeneration &+= 1 + failedSendConversationIDs = [] recomputeSnapshot() } @@ -320,6 +323,8 @@ extension ChatViewModel { // Skip the trailing preview load if this reload was superseded. if Task.isCancelled { return } await loadLastMessagePreviews() + if Task.isCancelled { return } + await refreshFailedSendIndicators() } // MARK: - Message Previews @@ -391,4 +396,61 @@ extension ChatViewModel { } } } + + // MARK: - Failed-send indicators + + /// True when the conversation row for `id` should show the failed-send badge. + func conversationHasFailedSend(_ id: UUID) -> Bool { + failedSendConversationIDs.contains(id) + } + + /// Replaces `failedSendConversationIDs` with the union of the store's list ids. + func applyFailedSendKeys(_ keys: FailedSendConversationKeys) { + failedSendConversationIDs = keys.contactIDs + .union(keys.channelIDs) + .union(keys.roomSessionIDs) + } + + /// Re-runs the failed-send query and applies the keys. Does not reload + /// contacts, channels, or rooms. + func refreshFailedSendIndicators() async { + failedSendRefreshGeneration &+= 1 + let generation = failedSendRefreshGeneration + guard let dataStore, let radioID = currentRadioIDProvider() else { + failedSendConversationIDs = [] + return + } + do { + #if DEBUG + try failedSendRefreshFaultInjection?() + #endif + let keys = try await dataStore.fetchFailedSendConversationKeys(radioID: radioID) + #if DEBUG + await failedSendRefreshInterleaveHook?() + #endif + guard generation == failedSendRefreshGeneration, !Task.isCancelled else { return } + applyFailedSendKeys(keys) + } catch is CancellationError { + return + } catch { + guard generation == failedSendRefreshGeneration, !Task.isCancelled else { return } + logger.warning("Failed to load failed-send indicators: \(error)") + } + } + + /// Whether a list `MessageEvent` should trigger `refreshFailedSendIndicators()`. + /// Status-resolved ACKs (DM and room) only requery when a badge is already + /// showing, so a retry success can drop it without hitting the store on every ACK. + func shouldRefreshFailedSendIndicators(for event: MessageEvent) -> Bool { + switch event { + case .messageFailed, .messageResent, .roomMessageFailed: + true + case .messageStatusResolved, .roomMessageStatusUpdated: + !failedSendConversationIDs.isEmpty + case .directMessageReceived, .channelMessageReceived, .roomMessageReceived, + .messageRetrying, .heardRepeatRecorded, .reactionReceived, + .messagesRegionUpdated, .routingChanged: + false + } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift index f5c60b1e4..a11d0d9df 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift @@ -15,7 +15,15 @@ extension ChatViewModel { /// invalidates dependent views once per change cycle without an explicit /// transaction. func appendMessageIfNew(_ message: MessageDTO) { - guard timeline.admit(message) else { return } + let admission = timeline.admit(message) + guard admission.inserted else { return } + if let handoff = admission.handoff { + incomingAvatarFlight?.beginFlight( + from: handoff.fromID, + to: handoff.toID, + identity: handoff.identity + ) + } if let senderName = message.senderNodeName, let radioID = currentChannel?.radioID { addChannelSenderIfNew(senderName, radioID: radioID, timestamp: message.timestamp) diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift index 00e0f710e..46db94afd 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift @@ -45,14 +45,21 @@ extension ChatViewModel { // not coalescer-eligible because attempt/maxAttempts are per-event. timeline.enqueueReload(messageID: messageID) - case let .messageResent(messageID), - let .messageFailed(messageID): + case let .messageResent(messageID): timeline.enqueueReload(messageID: messageID) + case let .messageFailed(messageID): + timeline.enqueueReload(messageID: messageID) + await markFailedSendSeenIfCurrent(messageID: messageID) + case let .heardRepeatRecorded(messageID, _), let .reactionReceived(messageID, _): timeline.enqueueReload(messageID: messageID) + case let .messagesRegionUpdated(messageIDs): + // Region reprocess rewrote dual fields; re-fetch to re-bake region chips. + timeline.enqueueReload(updatedMessageIDs: Set(messageIDs)) + case let .routingChanged(contactID, _): guard let current = currentContact, current.id == contactID else { return } requestContactRefresh() @@ -70,6 +77,41 @@ extension ChatViewModel { contactRefreshSignal &+= 1 } + /// Local enqueue/retry threw before the send queue could emit `.messageFailed`. + /// Writes `.failed` (which resets `failureSeen`) then marks the open thread + /// seen so the list badge does not reappear on pop-back. + func recordLocalEnqueueFailure(messageID: UUID, error: Error) async { + logger.error("enqueue failed for messageID=\(messageID, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + timeline.applyStatusUpdate(messageID: messageID, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + await markFailedSendSeenIfCurrent(messageID: messageID) + } + + /// A fail that lands while this thread is open is already on screen; mark it + /// seen so the list badge does not reappear on pop-back. + private func markFailedSendSeenIfCurrent(messageID: UUID) async { + guard let dataStore, let message = try? await dataStore.fetchMessage(id: messageID) else { + return + } + do { + if let contact = currentContact, message.contactID == contact.id { + try await dataStore.markFailedSendsSeen(contactID: contact.id) + syncCoordinator?.notifyConversationsChanged() + } else if let channel = currentChannel, + message.channelIndex == channel.index, + message.radioID == channel.radioID { + try await dataStore.markFailedSendsSeen( + radioID: channel.radioID, + channelIndex: channel.index + ) + syncCoordinator?.notifyConversationsChanged() + } + } catch { + logger.warning("Failed to mark in-thread failed send seen: \(error.localizedDescription)") + } + } + private func recordIncomingMentionIfNeeded(_ message: MessageDTO) { guard message.containsSelfMention else { return } mentionSequence &+= 1 diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift index a0c8ffd4b..b7032551e 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift @@ -7,10 +7,15 @@ extension ChatViewModel { // MARK: - Item Build /// Assemble `MessageBuildInputs` from current bake and env state. - func makeBuildInputs(for message: MessageDTO, previous: MessageDTO?) -> MessageBuildInputs { + func makeBuildInputs( + for message: MessageDTO, + previous: MessageDTO?, + next: MessageDTO? + ) -> MessageBuildInputs { bake.makeBuildInputs( for: message, previous: previous, + next: next, envInputs: envInputs, senderTables: currentSenderTables() ) diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift index b43c6644f..9d0da471c 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift @@ -72,10 +72,7 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } else { // Identity-preserving DM resend. Mirrors retryMessage: route through diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift index 23e0e0af6..fa228d01a 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift @@ -8,13 +8,52 @@ extension ChatViewModel { func loadAllContacts(radioID: UUID) async { guard let dataStore else { return } + incomingAvatarLoadGeneration += 1 + let generation = incomingAvatarLoadGeneration + do { allContacts = try await dataStore.fetchContacts(radioID: radioID) contactNameSet = Set(allContacts.map(\.name)) nicknamesByLoweredName = MessageBubbleConfiguration.buildNicknameLookup(from: allContacts) } catch { logger.warning("Failed to load contacts for mentions: \(error.localizedDescription)") + return + } + + guard case .channel = timeline.conversation else { return } + guard !Task.isCancelled, generation == incomingAvatarLoadGeneration else { return } + let contacts = allContacts + let newMap = await incomingAvatarIdentitiesOffMain(from: contacts) + guard !Task.isCancelled, generation == incomingAvatarLoadGeneration else { return } + IncomingAvatarJPEGStore.replace(contacts: contacts, identities: Array(newMap.values)) + guard newMap != incomingAvatarIdentitiesByLoweredName else { return } + incomingAvatarIdentitiesByLoweredName = newMap + let didChange = patchIncomingAvatarRows(using: newMap) + if !didChange, timeline.writer != nil { + // Bump renderStateID so an in-flight applyRebuiltItems misses and + // rebakeAll reads live senderTablesProvider(). + timeline.writer?.updateRenderState { $0 } + } + } + + /// Rewrites cluster-end identities after a channel contact-table change. + @discardableResult + private func patchIncomingAvatarRows(using map: [String: IncomingAvatarIdentity]) -> Bool { + guard let writer = timeline.writer else { return false } + var didChange = false + for item in items where item.envelope.incomingAvatar != nil { + let identity = IncomingAvatarIdentity.resolve( + senderNodeName: timeline.messagesByID[item.id]?.senderNodeName, + displayName: item.envelope.senderName, + table: map + ) + guard item.envelope.incomingAvatar != identity else { continue } + writer.updateRenderItem(id: item.id) { item in + item.with(envelope: item.envelope.with(incomingAvatar: identity)) + } + didChange = true } + return didChange } // MARK: - Messages @@ -23,11 +62,12 @@ extension ChatViewModel { /// coordinator, then clears unread state. Delegates the coordinator population /// to `primeInitialMessages(for:)`; the unread/badge/notify side effects here /// run only when that load succeeded. - func loadMessages(for contact: ContactDTO) async { + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. + func loadMessages(for contact: ContactDTO, populateMode: ChatPopulateMode) async { // Track active conversation for notification suppression notificationService?.setActiveConversation(contactID: contact.id) - guard await primeInitialMessages(for: contact) else { return } + guard await primeInitialMessages(for: contact, populateMode: populateMode) else { return } // Clear unread count and mention badge, then notify UI to refresh chat list. // The messages already rendered, so a bookkeeping failure here is logged @@ -35,6 +75,7 @@ extension ChatViewModel { do { try await dataStore?.clearUnreadCount(contactID: contact.id) try await dataStore?.clearUnreadMentionCount(contactID: contact.id) + try await dataStore?.markFailedSendsSeen(contactID: contact.id) } catch { logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") } @@ -44,14 +85,15 @@ extension ChatViewModel { await notificationService?.updateBadgeCount() } - /// Populates the bound coordinator with the first page for `contact` and builds - /// its render items — with no notification, unread-clearing, or badge side - /// effects. Safe to run before navigation to warm the coordinator so the + /// Populates the bound coordinator for `contact` and builds its render + /// items — with no notification, unread-clearing, or badge side effects. + /// Safe to run before navigation to warm the coordinator so the /// conversation renders populated on the first frame instead of popping in a /// frame after the push transition. `loadMessages` layers the open-time side - /// effects on top. Returns true when the fetch succeeded. + /// effects on top. + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. @discardableResult - func primeInitialMessages(for contact: ContactDTO) async -> Bool { + func primeInitialMessages(for contact: ContactDTO, populateMode: ChatPopulateMode) async -> Bool { // Clear preview state only when switching away from a previously loaded // conversation. A fresh view model has nothing to clear, and its cells // may already be fetching previews for this same conversation (warm @@ -77,7 +119,11 @@ extension ChatViewModel { ChatTimeline.ReactionIndexing(service: $0, scope: .direct(contact)) } - let outcome = await timeline.open(.dm(contact), reactions: reactions) + let outcome = await timeline.open( + .dm(contact), + reactions: reactions, + populateMode: populateMode + ) let didLoad: Bool switch outcome { @@ -160,10 +206,14 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } } + +@concurrent +private func incomingAvatarIdentitiesOffMain( + from contacts: [ContactDTO] +) async -> [String: IncomingAvatarIdentity] { + MessageBubbleConfiguration.incomingAvatarIdentities(from: contacts) +} diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift new file mode 100644 index 000000000..989a1cd16 --- /dev/null +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift @@ -0,0 +1,159 @@ +import Foundation +import MC1Services + +extension ChatViewModel { + /// In-bubble Translation action. Show original and cache hits skip a session. + /// One in-flight translation per conversation. + func performTranslationAction(for messageID: UUID) { + if case .showing = bake.translationPhases[messageID] { + bake.translationPhases[messageID] = .offer + timeline.rebakeRow(messageID) + return + } + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode( + envInputs.preferredLanguageCode + ) + if let cached = bake.translationCache[messageID]?[targetLanguageCode] { + resetInProgressTranslations() + translationSessionRequest = nil + bake.translationPhases[messageID] = .showing( + translatedText: cached, + targetLanguageCode: targetLanguageCode + ) + timeline.rebakeRow(messageID) + return + } + if case .inProgress = bake.translationPhases[messageID] { + return + } + resetInProgressTranslations() + guard let sourceLanguageCode = bake.detectedLanguages[messageID]?.code else { + return + } + bake.translationPhases[messageID] = .inProgress + timeline.rebakeRow(messageID) + translationGeneration += 1 + translationSessionRequest = TranslationSessionRequest( + messageID: messageID, + sourceLanguageCode: sourceLanguageCode, + targetLanguageCode: targetLanguageCode, + generation: translationGeneration + ) + } + + /// Apply `translator` only while `request.generation` is still current. + /// Never writes `MessageDTO.text`. + func performPendingTranslation( + using translator: any MessageTranslating, + for request: TranslationSessionRequest + ) async -> TranslationPerformResult { + guard isCurrent(request) else { return .finished } + let capturedID = request.messageID + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode( + request.targetLanguageCode + ) + guard let text = messagesByID[capturedID]?.text else { + guard isCurrent(request) else { return .finished } + bake.translationPhases[capturedID] = .offer + translationSessionRequest = nil + timeline.rebakeRow(capturedID) + return .finished + } + + do { + let result = try await translator.translate(text) + guard isCurrent(request) else { return .finished } + var perTarget = bake.translationCache[capturedID] ?? [:] + perTarget[targetLanguageCode] = result + bake.translationCache[capturedID] = perTarget + bake.translationPhases[capturedID] = .showing( + translatedText: result, + targetLanguageCode: targetLanguageCode + ) + translationSessionRequest = nil + timeline.rebakeRow(capturedID) + return .finished + } catch is MessageTranslationNeedsDownloadError { + guard isCurrent(request) else { return .finished } + return .needsDownload + } catch { + guard isCurrent(request) else { return .finished } + bake.translationPhases[capturedID] = .offer + translationSessionRequest = nil + timeline.rebakeRow(capturedID) + if isQuietTranslationCancel(error) { + return .finished + } + return .presentSystemOverlay(text: text) + } + } + + /// Returns `.inProgress` rows to the Translation offer and rebakes them. + /// Leaves `.showing` and the translation cache for a surviving view model. + func cancelPendingTranslation() { + translationGeneration += 1 + translationSessionRequest = nil + let idsToReset = bake.translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + for id in idsToReset { + bake.translationPhases[id] = .offer + timeline.rebakeRow(id) + } + } + + /// Drops in-flight work and showing chrome whose target is not `preferredLanguageCode`. + func invalidateTranslations(preferredLanguageCode: String) { + translationGeneration += 1 + translationSessionRequest = nil + let preferred = MessageLanguageDetector.collapsedLanguageCode(preferredLanguageCode) + for (id, phase) in bake.translationPhases { + switch phase { + case .inProgress: + bake.translationPhases[id] = .offer + case let .showing(_, target) where !MessageLanguageDetector.isSameLanguage(target, preferred): + bake.translationPhases[id] = .offer + case .offer, .showing: + break + } + } + } + + /// Clipboard-only. Reads the same chrome the bubble draws. Reply, Send Again, + /// reaction hash, and preview stay on stored `message.text`. + func displayedText(for message: MessageDTO) -> String { + if case let .showing(translatedText, _) = translation(for: message.id)?.phase { + return translatedText + } + return message.text + } + + func translation(for messageID: UUID) -> MessageTranslationChrome? { + items.first { $0.id == messageID }?.translation + } + + /// Resets every in-flight row back to the Translation offer and bumps + /// generation so an in-flight result is discarded. + private func resetInProgressTranslations() { + let inProgressIDs = bake.translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + guard !inProgressIDs.isEmpty else { return } + translationGeneration += 1 + for id in inProgressIDs { + bake.translationPhases[id] = .offer + timeline.rebakeRow(id) + } + } + + private func isCurrent(_ request: TranslationSessionRequest) -> Bool { + translationSessionRequest?.generation == request.generation + } + + /// `CancellationError`, or `CocoaError.userCancelled` bridged as NSError. + private func isQuietTranslationCancel(_ error: Error) -> Bool { + if error is CancellationError { return true } + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } +} diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 5e4bfdd55..53e53fed3 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -46,6 +46,13 @@ final class ChatViewModel { /// `allContacts` changes so per-message resolution stays O(1). var nicknamesByLoweredName: [String: String] = [:] + /// Unique lowered contact name → channel incoming-avatar identity. + var incomingAvatarIdentitiesByLoweredName: [String: IncomingAvatarIdentity] = [:] + + /// Bumped at the start of each `loadAllContacts` so a slower first fetch + /// cannot publish after a newer overlapping load. + @ObservationIgnored var incomingAvatarLoadGeneration = 0 + /// Synthetic contacts for channel senders not in contacts var channelSenders: [ContactDTO] = [] @@ -76,10 +83,22 @@ final class ChatViewModel { /// Cancel-and-replace token for the serialized reload funnel. No view reads it. @ObservationIgnored var reloadTask: Task? + /// View-owned drop animation. Weak so a disappeared conversation cannot + /// start a flight after its overlay is gone. + @ObservationIgnored weak var incomingAvatarFlight: IncomingAvatarFlight? + #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)? + + /// Test-only interleave hook, awaited after the failed-send keys fetch so a + /// test can suspend refresh #1 until a newer refresh has applied. + @ObservationIgnored var failedSendRefreshInterleaveHook: (@MainActor () async -> Void)? + + /// Test-only fault injection fired immediately before the failed-send keys + /// fetch so tests can exercise the keep-last-known catch path. + @ObservationIgnored var failedSendRefreshFaultInjection: (@MainActor () throws -> Void)? #endif // MARK: - Conversation Cache Storage @@ -126,6 +145,12 @@ final class ChatViewModel { /// 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) { + if !MessageLanguageDetector.isSameLanguage( + envInputs.preferredLanguageCode, + new.preferredLanguageCode + ) { + invalidateTranslations(preferredLanguageCode: new.preferredLanguageCode) + } timeline.applyEnvInputs(new) } @@ -177,6 +202,14 @@ final class ChatViewModel { /// Error message if any var errorMessage: String? + /// Pending Translation session request. Observed by the conversation view + /// so it can invalidate `TranslationSession.Configuration`. + var translationSessionRequest: TranslationSessionRequest? + + /// Monotonic generation for in-flight translation. Apply a result only + /// when it still matches `translationSessionRequest.generation`. + @ObservationIgnored var translationGeneration: UInt64 = 0 + /// 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 @@ -211,6 +244,14 @@ final class ChatViewModel { /// Last message previews cache var lastMessageCache: [UUID: MessageDTO] = [:] + /// Conversation ids (contact, channel, or room session) with at least one + /// unseen outgoing `.failed` send. Observed so list rows invalidate like previews. + var failedSendConversationIDs: Set = [] + + /// Bumped at the start of each failed-send indicator refresh so a slower + /// first fetch cannot publish after a newer overlapping refresh. + @ObservationIgnored var failedSendRefreshGeneration = 0 + /// Scope preferences (master + per-conversation-type auto-resolve) read by /// the image fetch sites so inline images honor the same DM/channel gate the /// card path applies inside `LinkPreviewCache`. Internal so tests can inject @@ -258,7 +299,11 @@ final class ChatViewModel { /// Snapshot of observed contact tables for the item bake. func currentSenderTables() -> ChatSenderTables { - ChatSenderTables(contacts: allContacts, nicknamesByLoweredName: nicknamesByLoweredName) + ChatSenderTables( + contacts: allContacts, + nicknamesByLoweredName: nicknamesByLoweredName, + incomingAvatars: incomingAvatarIdentitiesByLoweredName + ) } // MARK: - Dependencies diff --git a/MC1/Views/Components/AvatarCropGeometry.swift b/MC1/Views/Components/AvatarCropGeometry.swift new file mode 100644 index 000000000..c63d725ea --- /dev/null +++ b/MC1/Views/Components/AvatarCropGeometry.swift @@ -0,0 +1,89 @@ +import CoreGraphics + +/// Pan, zoom, and crop-guide math for `AvatarCropView`. +struct AvatarCropGeometry: Equatable { + static let minScale: CGFloat = 1 + static let maxScale: CGFloat = 4 + static let compactMaxCropSize: CGFloat = 300 + static let regularMaxCropSize: CGFloat = 480 + static let cropMargin: CGFloat = 32 + static let outputSide: CGFloat = 512 + static let decodeMaxPixelSize: CGFloat = 1024 + static let zoomStep: CGFloat = 0.25 + static let panStepFraction: CGFloat = 0.125 + + var cropSize: CGFloat + var imageSize: CGSize + var scale: CGFloat = 1 + var offset: CGSize = .zero + + static func cropSize(in available: CGSize, isRegularWidth: Bool) -> CGFloat { + let maxSide = isRegularWidth ? regularMaxCropSize : compactMaxCropSize + let shortest = min(available.width, available.height) + let fitted = min(maxSide, shortest - cropMargin * 2) + return max(fitted, 1) + } + + var baseDisplaySize: CGSize { + guard imageSize.width > 0, imageSize.height > 0 else { + return CGSize(width: cropSize, height: cropSize) + } + let fillScale = max(cropSize / imageSize.width, cropSize / imageSize.height) + return CGSize(width: imageSize.width * fillScale, height: imageSize.height * fillScale) + } + + func clampedScale(_ proposed: CGFloat) -> CGFloat { + min(max(proposed, Self.minScale), Self.maxScale) + } + + /// Keeps the crop square fully covered at the given scale. + func clampedOffset(_ proposed: CGSize, scale: CGFloat? = nil) -> CGSize { + let usedScale = scale ?? self.scale + let displayed = CGSize( + width: baseDisplaySize.width * usedScale, + height: baseDisplaySize.height * usedScale + ) + let maxX = max(0, (displayed.width - cropSize) / 2) + let maxY = max(0, (displayed.height - cropSize) / 2) + return CGSize( + width: min(max(proposed.width, -maxX), maxX), + height: min(max(proposed.height, -maxY), maxY) + ) + } + + func liveTransform(pinchDelta: CGFloat, dragTranslation: CGSize) -> (scale: CGFloat, offset: CGSize) { + let liveScale = clampedScale(scale * pinchDelta) + let proposed = CGSize( + width: offset.width + dragTranslation.width, + height: offset.height + dragTranslation.height + ) + return (liveScale, clampedOffset(proposed, scale: liveScale)) + } + + func imageDrawRect(outputSide: CGFloat) -> CGRect { + let displayed = CGSize( + width: baseDisplaySize.width * scale, + height: baseDisplaySize.height * scale + ) + let origin = CGPoint( + x: (cropSize - displayed.width) / 2 + offset.width, + y: (cropSize - displayed.height) / 2 + offset.height + ) + let renderScale = outputSide / cropSize + return CGRect( + x: origin.x * renderScale, + y: origin.y * renderScale, + width: displayed.width * renderScale, + height: displayed.height * renderScale + ) + } + + mutating func applyZoomStep(_ delta: CGFloat) { + scale = clampedScale(scale + delta) + offset = clampedOffset(offset) + } + + mutating func applyPan(width: CGFloat, height: CGFloat) { + offset = clampedOffset(CGSize(width: offset.width + width, height: offset.height + height)) + } +} diff --git a/MC1/Views/Components/AvatarCropView.swift b/MC1/Views/Components/AvatarCropView.swift new file mode 100644 index 000000000..1f59354aa --- /dev/null +++ b/MC1/Views/Components/AvatarCropView.swift @@ -0,0 +1,195 @@ +import SwiftUI +import UIKit + +/// Lets the user pan and zoom a picked image within a circular guide before it's +/// saved as a contact's profile picture, and crops it down to just that region. +struct AvatarCropView: View { + let image: UIImage + let onCancel: () -> Void + let onComplete: (UIImage) -> Void + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @GestureState private var dragTranslation: CGSize = .zero + @GestureState private var pinchDelta: CGFloat = 1 + + @State private var geometry = AvatarCropGeometry( + cropSize: AvatarCropGeometry.compactMaxCropSize, + imageSize: .zero + ) + + private let guideLineWidth: CGFloat = 2 + private let dimOpacity = 0.5 + + var body: some View { + NavigationStack { + GeometryReader { proxy in + let cropSize = AvatarCropGeometry.cropSize( + in: proxy.size, + isRegularWidth: horizontalSizeClass == .regular + ) + canvas(cropSize: cropSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: cropSize) { _, newSize in + applyCropSize(newSize) + } + .onAppear { + applyCropSize(cropSize) + } + } + .background(Color.black.ignoresSafeArea()) + .navigationTitle(L10n.Contacts.Contacts.Detail.Avatar.Crop.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Common.cancel, action: onCancel) + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.choose) { + confirmCrop() + } + } + } + .toolbarBackground(.hidden, for: .navigationBar) + .tint(.white) + } + .onAppear { + geometry.imageSize = image.size + } + } + + private func canvas(cropSize: CGFloat) -> some View { + let resolved = resolvedGeometry(cropSize: cropSize) + let live = resolved.liveTransform(pinchDelta: pinchDelta, dragTranslation: dragTranslation) + let panStep = cropSize * AvatarCropGeometry.panStepFraction + + return ZStack { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: resolved.baseDisplaySize.width, height: resolved.baseDisplaySize.height) + .scaleEffect(live.scale) + .offset(x: live.offset.width, y: live.offset.height) + .allowsHitTesting(false) + + Circle() + .strokeBorder(Color.white, lineWidth: guideLineWidth) + .frame(width: cropSize, height: cropSize) + .allowsHitTesting(false) + + dimmingMask(cropSize: cropSize) + .allowsHitTesting(false) + } + .contentShape(Rectangle()) + .gesture(dragGesture(cropSize: cropSize)) + .simultaneousGesture(magnificationGesture(cropSize: cropSize)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.Avatar.Crop.preview) + .accessibilityHint(L10n.Contacts.Contacts.Detail.Avatar.Crop.previewHint) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + geometry.applyZoomStep(AvatarCropGeometry.zoomStep) + case .decrement: + geometry.applyZoomStep(-AvatarCropGeometry.zoomStep) + default: + break + } + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveUp) { + geometry.applyPan(width: 0, height: -panStep) + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveDown) { + geometry.applyPan(width: 0, height: panStep) + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveLeft) { + geometry.applyPan(width: -panStep, height: 0) + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveRight) { + geometry.applyPan(width: panStep, height: 0) + } + } + + /// A full-bleed dark scrim with a circular window cut out over the crop guide, + /// drawn with an even-odd fill so the two shapes combine into a single hole-punched path. + private func dimmingMask(cropSize: CGFloat) -> some View { + GeometryReader { proxy in + Path { path in + path.addRect(CGRect(origin: .zero, size: proxy.size)) + let circleRect = CGRect( + x: (proxy.size.width - cropSize) / 2, + y: (proxy.size.height - cropSize) / 2, + width: cropSize, + height: cropSize + ) + path.addEllipse(in: circleRect) + } + .fill(Color.black.opacity(dimOpacity), style: FillStyle(eoFill: true)) + } + } + + private func dragGesture(cropSize: CGFloat) -> some Gesture { + DragGesture() + .updating($dragTranslation) { value, state, _ in + state = value.translation + } + .onEnded { value in + commitLiveTransform(cropSize: cropSize, pinchDelta: pinchDelta, dragTranslation: value.translation) + } + } + + private func magnificationGesture(cropSize: CGFloat) -> some Gesture { + MagnifyGesture() + .updating($pinchDelta) { value, state, _ in + state = value.magnification + } + .onEnded { value in + commitLiveTransform(cropSize: cropSize, pinchDelta: value.magnification, dragTranslation: dragTranslation) + } + } + + private func confirmCrop() { + let live = resolvedGeometry(cropSize: geometry.cropSize) + .liveTransform(pinchDelta: pinchDelta, dragTranslation: dragTranslation) + var snapshot = resolvedGeometry(cropSize: geometry.cropSize) + snapshot.scale = live.scale + snapshot.offset = live.offset + onComplete(croppedImage(snapshot)) + } + + /// Draws via `UIImage.draw(in:)` so EXIF orientation matches the on-screen preview. + private func croppedImage(_ snapshot: AvatarCropGeometry) -> UIImage { + let outputSide = AvatarCropGeometry.outputSide + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: outputSide, height: outputSide), + format: format + ) + return renderer.image { _ in + image.draw(in: snapshot.imageDrawRect(outputSide: outputSide)) + } + } + + private func resolvedGeometry(cropSize: CGFloat) -> AvatarCropGeometry { + var resolved = geometry + resolved.cropSize = cropSize + resolved.imageSize = image.size + return resolved + } + + private func commitLiveTransform(cropSize: CGFloat, pinchDelta: CGFloat, dragTranslation: CGSize) { + let live = resolvedGeometry(cropSize: cropSize) + .liveTransform(pinchDelta: pinchDelta, dragTranslation: dragTranslation) + geometry.cropSize = cropSize + geometry.imageSize = image.size + geometry.scale = live.scale + geometry.offset = live.offset + } + + private func applyCropSize(_ cropSize: CGFloat) { + geometry.cropSize = cropSize + geometry.imageSize = image.size + geometry.offset = geometry.clampedOffset(geometry.offset) + } +} diff --git a/MC1/Views/Components/BLEStatusIndicatorView.swift b/MC1/Views/Components/BLEStatusIndicatorView.swift index 09cc9ca68..371304074 100644 --- a/MC1/Views/Components/BLEStatusIndicatorView.swift +++ b/MC1/Views/Components/BLEStatusIndicatorView.swift @@ -10,7 +10,6 @@ private let logger = Logger(subsystem: "com.mc1", category: "BLEStatus") 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 @@ -42,9 +41,6 @@ struct BLEStatusIndicatorView: View { .presentationDetents([.medium]) .presentationDragIndicator(.visible) } - .navigationDestination(isPresented: $showingAdvancedSettings) { - AdvancedSettingsView() - } } // MARK: - Menu Content @@ -106,7 +102,7 @@ struct BLEStatusIndicatorView: View { Section { Button { - showingAdvancedSettings = true + appState.navigation.navigateToSetting(.advanced) } label: { Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") } diff --git a/MC1/Views/Components/ContactAvatar.swift b/MC1/Views/Components/ContactAvatar.swift index b096508dd..4e137b856 100644 --- a/MC1/Views/Components/ContactAvatar.swift +++ b/MC1/Views/Components/ContactAvatar.swift @@ -25,9 +25,13 @@ struct ContactAvatar: View { } init(name: String, size: CGFloat) { + self.init(name: name, size: size, imageData: nil) + } + + init(name: String, size: CGFloat, imageData: Data?) { self.name = name self.size = size - imageData = nil + self.imageData = imageData } var body: some View { diff --git a/MC1/Views/Components/RegionManagementView.swift b/MC1/Views/Components/RegionManagementView.swift index ecbbdb3a8..d25ff5710 100644 --- a/MC1/Views/Components/RegionManagementView.swift +++ b/MC1/Views/Components/RegionManagementView.swift @@ -57,7 +57,8 @@ struct RegionManagementView: View { return } validationMessage = nil - onAddRegion(newRegionName.trimmingCharacters(in: .whitespaces)) + let name = newRegionName.trimmingCharacters(in: .whitespaces) + Task { onAddRegion(name) } } Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { validationMessage = nil diff --git a/MC1/Views/Components/WiFiAddressFields.swift b/MC1/Views/Components/WiFiAddressFields.swift index 1aafeedc7..2f85866db 100644 --- a/MC1/Views/Components/WiFiAddressFields.swift +++ b/MC1/Views/Components/WiFiAddressFields.swift @@ -4,7 +4,15 @@ enum WiFiField: Hashable { case ipAddress, port } -/// Shared IP address and port input fields used by WiFi connection sheets. +private enum WiFiHostLimits { + static let hostnameMaxLength = 253 + static let hostnameLabelMaxLength = 63 + static let hostnameLabelCharacters = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-" + ) +} + +/// Shared host and port input fields used by WiFi connection sheets. struct WiFiAddressFields: View { @Binding var ipAddress: String @Binding var port: String @@ -23,8 +31,7 @@ struct WiFiAddressFields: View { Section { HStack { TextField(L10n.Onboarding.WifiConnection.IpAddress.placeholder, text: $ipAddress) - .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .decimalPad) - .environment(\.locale, Locale(identifier: "en_US")) + .keyboardType(.URL) .textContentType(.none) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -81,7 +88,21 @@ struct WiFiAddressFields: View { // MARK: - Validation - static func isValidIPAddress(_ ip: String) -> Bool { + /// Host checks do not touch view state, so they stay off the main actor. + nonisolated static func isValidHost(_ raw: String) -> Bool { + let host = normalizedHost(raw) + guard !host.isEmpty else { return false } + if looksLikeIPv4(host) { + return isValidIPAddress(host) + } + return isValidHostname(host) + } + + nonisolated static func normalizedHost(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + nonisolated static func isValidIPAddress(_ ip: String) -> Bool { let parts = ip.split(separator: ".") guard parts.count == 4 else { return false } return parts.allSatisfy { part in @@ -90,8 +111,32 @@ struct WiFiAddressFields: View { } } - static func isValidPort(_ port: String) -> Bool { + nonisolated static func isValidPort(_ port: String) -> Bool { guard let num = UInt16(port) else { return false } return num > 0 } + + private nonisolated static func looksLikeIPv4(_ host: String) -> Bool { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty else { return false } + return parts.allSatisfy { part in + !part.isEmpty && part.allSatisfy { $0.isASCII && $0.isNumber } + } + } + + private nonisolated static func isValidHostname(_ host: String) -> Bool { + let candidate = host.hasSuffix(".") ? String(host.dropLast()) : host + guard (1...WiFiHostLimits.hostnameMaxLength).contains(candidate.count) else { return false } + let labels = candidate.split(separator: ".", omittingEmptySubsequences: false) + return !labels.isEmpty && labels.allSatisfy(isValidHostnameLabel) + } + + private nonisolated static func isValidHostnameLabel(_ label: Substring) -> Bool { + guard (1...WiFiHostLimits.hostnameLabelMaxLength).contains(label.count) else { return false } + guard let first = label.first, let last = label.last else { return false } + guard label.unicodeScalars.allSatisfy({ WiFiHostLimits.hostnameLabelCharacters.contains($0) }) else { + return false + } + return first != "-" && last != "-" + } } diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 72a672789..d5048af8f 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -73,6 +73,12 @@ struct ContactDetailView: View { } } + /// Wraps a decoded avatar image so `fullScreenCover(item:)` can't present an empty cover. + private struct AvatarCropRequest: Identifiable { + let id = UUID() + let image: UIImage + } + @State private var currentContact: ContactDTO @State private var nickname = "" @State private var isEditingNickname = false @@ -105,6 +111,10 @@ struct ContactDetailView: View { @State private var showAvatarFileImporter = false @State private var avatarPickerItem: PhotosPickerItem? @State private var isSavingAvatar = false + /// A decoded image waiting for the photo picker / file importer sheet that produced it + /// to finish dismissing, so the crop cover isn't presented while another is still animating out. + @State private var pendingCropImage: UIImage? + @State private var cropRequest: AvatarCropRequest? init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { self.contact = contact @@ -128,7 +138,10 @@ struct ContactDetailView: View { contactTypeLabel: contactTypeLabel, measuredHeight: $headerHeight, isSavingAvatar: isSavingAvatar, - onEditAvatar: { showAvatarSourceMenu = true } + showAvatarSourceMenu: $showAvatarSourceMenu, + onChooseAvatarPhoto: { showAvatarPhotosPicker = true }, + onChooseAvatarFile: { showAvatarFileImporter = true }, + onRemoveAvatar: { Task { await removeAvatar() } } ) // Quick actions @@ -255,27 +268,29 @@ struct ContactDetailView: View { .onAppear { nickname = currentContact.nickname ?? "" } - .confirmationDialog( - L10n.Contacts.Contacts.Detail.Avatar.chooseSource, - isPresented: $showAvatarSourceMenu, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Detail.Avatar.choosePhoto) { showAvatarPhotosPicker = true } - Button(L10n.Contacts.Contacts.Detail.Avatar.chooseFile) { showAvatarFileImporter = true } - if currentContact.avatarImageData != nil { - Button(L10n.Contacts.Contacts.Detail.Avatar.removePhoto, role: .destructive) { - Task { await removeAvatar() } - } - } - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} - } .photosPicker(isPresented: $showAvatarPhotosPicker, selection: $avatarPickerItem, matching: .images) .onChange(of: avatarPickerItem) { _, newItem in Task { await loadPickedAvatarPhoto(newItem) } } + .onChange(of: showAvatarPhotosPicker) { _, isPresented in + if !isPresented { presentPendingCropIfReady() } + } .fileImporter(isPresented: $showAvatarFileImporter, allowedContentTypes: [.image]) { result in Task { await handleAvatarFileImport(result) } } + .onChange(of: showAvatarFileImporter) { _, isPresented in + if !isPresented { presentPendingCropIfReady() } + } + .fullScreenCover(item: $cropRequest) { request in + AvatarCropView( + image: request.image, + onCancel: { cropRequest = nil }, + onComplete: { cropped in + cropRequest = nil + Task { await saveAvatar(image: cropped) } + } + ) + } .task { pathViewModel.configure( dataStore: { appState.services?.dataStore }, @@ -535,7 +550,7 @@ struct ContactDetailView: View { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage return } - await saveAvatar(data: data) + await presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -548,7 +563,7 @@ struct ContactDetailView: View { defer { if didAccess { url.stopAccessingSecurityScopedResource() } } do { let data = try Data(contentsOf: url) - await saveAvatar(data: data) + await presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -557,10 +572,37 @@ struct ContactDetailView: View { } } - private func saveAvatar(data: Data) async { + /// Decodes off the main actor and downsamples before crop, so a camera photo + /// cannot hitch the UI or hold a full-resolution bitmap. + private func presentCropSheet(data: Data) async { + let decoded = await Task.detached(priority: .userInitiated) { + ImageURLDetector.downsampledImage( + from: data, + maxPixelSize: AvatarCropGeometry.decodeMaxPixelSize + ) + }.value + guard let image = decoded else { + errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage + return + } + pendingCropImage = image + // Both sheets already closed: present now. Otherwise `presentPendingCropIfReady` + // runs when `showAvatarPhotosPicker` or `showAvatarFileImporter` becomes false. + if !showAvatarPhotosPicker, !showAvatarFileImporter { + presentPendingCropIfReady() + } + } + + private func presentPendingCropIfReady() { + guard let image = pendingCropImage else { return } + pendingCropImage = nil + cropRequest = AvatarCropRequest(image: image) + } + + private func saveAvatar(image: UIImage) async { isSavingAvatar = true let processed = await Task.detached(priority: .userInitiated) { - Self.processAvatarImage(data: data) + Self.processAvatarImage(image: image) }.value guard let processed else { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage @@ -590,12 +632,17 @@ struct ContactDetailView: View { isSavingAvatar = false } - /// Downscales to a max 512pt dimension and re-encodes as JPEG so avatars stay small in the store. - /// `nonisolated` so it can run on a background thread via `Task.detached` in `saveAvatar`. - private nonisolated static func processAvatarImage(data: Data) -> Data? { - guard let image = UIImage(data: data) else { return nil } - let maxDimension: CGFloat = 512 - let scale = min(1, maxDimension / max(image.size.width, image.size.height)) + private nonisolated static let avatarMaxDimension: CGFloat = 512 + private nonisolated static let avatarJPEGQuality: CGFloat = 0.8 + + /// Downscales to `avatarMaxDimension` and re-encodes as JPEG so avatars stay small in the store. + private nonisolated static func processAvatarImage(image: UIImage) -> Data? { + let longest = max(image.size.width, image.size.height) + guard longest > 0 else { return nil } + if longest <= avatarMaxDimension { + return image.jpegData(compressionQuality: avatarJPEGQuality) + } + let scale = avatarMaxDimension / longest let targetSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) let format = UIGraphicsImageRendererFormat() format.scale = 1 @@ -603,7 +650,7 @@ struct ContactDetailView: View { let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: targetSize)) } - return resized.jpegData(compressionQuality: 0.8) + return resized.jpegData(compressionQuality: avatarJPEGQuality) } // MARK: - Helpers @@ -633,11 +680,16 @@ struct ContactDetailView: View { private struct ContactDetailAvatarView: View { let contact: ContactDTO let isSavingAvatar: Bool - let onEditAvatar: () -> Void + @Binding var showSourceMenu: Bool + let onChoosePhoto: () -> Void + let onChooseFile: () -> Void + let onRemovePhoto: () -> Void var body: some View { if contact.type == .chat { - Button(action: onEditAvatar) { + Button { + showSourceMenu = true + } label: { ZStack { ContactAvatar(contact: contact, size: 150) if isSavingAvatar { @@ -653,6 +705,18 @@ private struct ContactDetailAvatarView: View { .buttonStyle(.plain) .disabled(isSavingAvatar) .accessibilityLabel(accessibilityLabel) + .confirmationDialog( + L10n.Contacts.Contacts.Detail.Avatar.chooseSource, + isPresented: $showSourceMenu, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Detail.Avatar.choosePhoto, action: onChoosePhoto) + Button(L10n.Contacts.Contacts.Detail.Avatar.chooseFile, action: onChooseFile) + if contact.avatarImageData != nil { + Button(L10n.Contacts.Contacts.Detail.Avatar.removePhoto, role: .destructive, action: onRemovePhoto) + } + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + } } else { switch contact.type { case .repeater: @@ -692,7 +756,10 @@ private struct ContactProfileSection: View { let contactTypeLabel: String @Binding var measuredHeight: CGFloat let isSavingAvatar: Bool - let onEditAvatar: () -> Void + @Binding var showAvatarSourceMenu: Bool + let onChooseAvatarPhoto: () -> Void + let onChooseAvatarFile: () -> Void + let onRemoveAvatar: () -> Void var body: some View { Section { @@ -700,7 +767,10 @@ private struct ContactProfileSection: View { ContactDetailAvatarView( contact: currentContact, isSavingAvatar: isSavingAvatar, - onEditAvatar: onEditAvatar + showSourceMenu: $showAvatarSourceMenu, + onChoosePhoto: onChooseAvatarPhoto, + onChooseFile: onChooseAvatarFile, + onRemovePhoto: onRemoveAvatar ) VStack(spacing: 4) { @@ -938,7 +1008,16 @@ private struct ContactInfoSection: View { .foregroundStyle(.secondary) } - // Last advert + // Phone-clock last heard (on-air) + if let lastHeard = currentContact.lastHeardTimestamp, lastHeard > 0 { + HStack { + Text(L10n.Contacts.Contacts.Detail.lastHeard) + Spacer() + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(lastHeard)), font: .body) + } + } + + // Radio-sourced last advert if currentContact.lastAdvertTimestamp > 0 { HStack { Text(L10n.Contacts.Contacts.Detail.lastAdvert) @@ -1357,6 +1436,7 @@ private struct ContactDangerSection: View { name: "Alice", latitude: 37.7749, longitude: -122.4194, + lastHeardTimestamp: 0, isFavorite: true ))) } @@ -1372,6 +1452,7 @@ private struct ContactDangerSection: View { name: "Alice", latitude: 37.7749, longitude: -122.4194, + lastHeardTimestamp: 0, isFavorite: true )), showFromDirectChat: true diff --git a/MC1/Views/Contacts/ContactRowView.swift b/MC1/Views/Contacts/ContactRowView.swift index ffc628cc0..01deb9074 100644 --- a/MC1/Views/Contacts/ContactRowView.swift +++ b/MC1/Views/Contacts/ContactRowView.swift @@ -57,7 +57,7 @@ struct ContactRowView: View { .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) } - RelativeTimestampText(timestamp: contact.lastModified) + RelativeTimestampText(timestamp: contact.recencyTimestamp) } HStack(spacing: 8) { diff --git a/MC1/Views/Contacts/ContactsViewModel.swift b/MC1/Views/Contacts/ContactsViewModel.swift index bce45dcc8..60b18e011 100644 --- a/MC1/Views/Contacts/ContactsViewModel.swift +++ b/MC1/Views/Contacts/ContactsViewModel.swift @@ -158,6 +158,8 @@ final class ContactsViewModel { syncProgress = nil errorMessage = nil + // `setSyncingContacts(true)` then `syncContactsForRefresh` so an advert-driven + // sync cannot interleave progress events with this refresh. if let advertisementService { await advertisementService.setSyncingContacts(true) } @@ -175,7 +177,7 @@ final class ContactsViewModel { defer { progressTask.cancel() } do { - _ = try await contactService.syncContacts(radioID: radioID) + _ = try await contactService.syncContactsForRefresh(radioID: radioID) // Reload from database await loadContacts(radioID: radioID) @@ -350,7 +352,7 @@ final class ContactsViewModel { ) -> [ContactDTO] { switch order { case .lastHeard: - contacts.sorted { $0.lastModified > $1.lastModified } + contacts.sorted { $0.recencyTimestamp > $1.recencyTimestamp } case .name: contacts.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index 68cd3150f..3e50b0592 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -83,6 +83,7 @@ struct ContactCalloutContent: View { typeRawValue: 0, latitude: 37.7749, longitude: -122.4194, + lastHeardTimestamp: 0, isFavorite: true ) ), diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift index af5dd33d9..941c20f2d 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -77,6 +77,12 @@ struct ContactDetailSheet: View { } } + if let lastHeard = contact.lastHeardTimestamp, lastHeard > 0 { + LabeledContent(L10n.Map.Map.Detail.lastHeard) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(lastHeard)), font: .body) + } + } + if contact.lastAdvertTimestamp > 0 { LabeledContent(L10n.Map.Map.Detail.lastAdvert) { ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index a5efc90e7..9e2fffb53 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -1,142 +1,10 @@ import MapKit import MapLibre -import ObjectiveC import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "MapPins") -// MARK: - MapLibre Metal scale fix - -/// Workaround for a MapLibre bug where `MLNEffectiveScaleFactorForView` -/// computes `nativeBounds.width / bounds.width` — a ratio that breaks in -/// landscape because `nativeBounds` is fixed while `bounds` rotates. -/// We intercept both `setDrawableSize:` and `setContentScaleFactor:` on -/// 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 - } - - 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 - } - 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 - } - - 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) - } - } - - 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 - } - - 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) - } -} - struct MC1MapView: UIViewRepresentable { // Data let points: [MapPoint] @@ -406,7 +274,7 @@ 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)) + let mapView = MLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) // Callbacks var onPointTap: ((MapPoint, CGPoint) -> Void)? diff --git a/MC1/Views/Onboarding/WiFiConnectionSheet.swift b/MC1/Views/Onboarding/WiFiConnectionSheet.swift index b510c58a9..9fb19ad30 100644 --- a/MC1/Views/Onboarding/WiFiConnectionSheet.swift +++ b/MC1/Views/Onboarding/WiFiConnectionSheet.swift @@ -68,7 +68,7 @@ private func ipv6AddressesOfBroadcastCapableInterfaces() -> [sockaddr_in6] { } } -/// Sheet for entering WiFi connection details (IP address and port). +/// Sheet for entering WiFi connection details (hostname or IP address and port). struct WiFiConnectionSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.appState) private var appState @@ -82,7 +82,7 @@ struct WiFiConnectionSheet: View { @FocusState private var focusedField: WiFiField? private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + WiFiAddressFields.isValidHost(ipAddress) && WiFiAddressFields.isValidPort(port) } private var usesFullKeyboardInput: Bool { @@ -154,7 +154,11 @@ struct WiFiConnectionSheet: View { Task { do { - try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) + try await appState.connectViaWiFi( + host: WiFiAddressFields.normalizedHost(ipAddress), + port: portNumber, + forceFullSync: true + ) await appState.wireServicesIfConnected() dismiss() // Navigate directly to radio settings diff --git a/MC1/Views/RemoteNodes/NeighborRow.swift b/MC1/Views/RemoteNodes/NeighborRow.swift index 3ab1c65b6..ec83c774d 100644 --- a/MC1/Views/RemoteNodes/NeighborRow.swift +++ b/MC1/Views/RemoteNodes/NeighborRow.swift @@ -5,6 +5,7 @@ struct NeighborRow: View { let neighbor: NeighbourInfo let displayName: String let matchKind: NodeNameMatchKind + let keyDisplayByteCount: Int let previousNeighbor: NeighborSnapshotEntry? let isNew: Bool @@ -12,12 +13,14 @@ struct NeighborRow: View { neighbor: NeighbourInfo, displayName: String, matchKind: NodeNameMatchKind, + keyDisplayByteCount: Int, previousNeighbor: NeighborSnapshotEntry? = nil, isNew: Bool = false ) { self.neighbor = neighbor self.displayName = displayName self.matchKind = matchKind + self.keyDisplayByteCount = keyDisplayByteCount self.previousNeighbor = previousNeighbor self.isNew = isNew } @@ -46,7 +49,7 @@ struct NeighborRow: View { } HStack(spacing: 4) { - Text(firstKeyByte) + Text(keyHex) .font(.system(.caption2, design: .monospaced)) Text("·") Text(lastSeenText) @@ -75,9 +78,11 @@ struct NeighborRow: View { } } - private var firstKeyByte: String { - guard let firstByte = neighbor.publicKeyPrefix.first else { return "" } - return Data([firstByte]).uppercaseHexString() + private var keyHex: String { + NeighborNameResolver.fallbackName( + for: neighbor.publicKeyPrefix, + byteCount: keyDisplayByteCount + ) } private var lastSeenText: String { diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 98ce67a05..6696c7061 100644 --- a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -499,7 +499,8 @@ private struct ConnectButton: View { radioID: UUID(), publicKey: Data(repeating: 0x42, count: 32), name: "Test Room", - typeRawValue: ContactType.room.rawValue + typeRawValue: ContactType.room.rawValue, + lastHeardTimestamp: 0 )), role: .roomServer, onSuccess: { _ in } diff --git a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift index 2e7826494..873c3e67b 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift @@ -18,6 +18,10 @@ final class NodeSettingsViewModel { var firmwareVersion: String? private var deviceTimeUTC: String? + /// Positive means the node's clock is ahead of `now`. + var clockDrift: TimeInterval? + /// Reference clock used when measuring `clockDrift`. + var now: () -> Date = Date.init var isLoadingDeviceInfo = false var deviceInfoError = false var deviceInfoLoaded: Bool { @@ -39,6 +43,13 @@ final class NodeSettingsViewModel { return "\(timeString) - \(dateString)" } + func applyDeviceTime(_ raw: String) { + if let text = NodeSettingsResponseParser.clockResponseText(in: raw) { + deviceTimeUTC = text + clockDrift = NodeSettingsResponseParser.clockDrift(fromClockResponse: text, relativeTo: now()) + } + } + // MARK: - Identity var name: String? @@ -225,7 +236,7 @@ final class NodeSettingsViewModel { do { let response = try await sendAndWait("clock") if case let .deviceTime(time) = CLIResponse.parse(response, forQuery: "clock") { - deviceTimeUTC = time + applyDeviceTime(time) } } catch { if case RemoteNodeError.timeout = error { @@ -546,9 +557,16 @@ final class NodeSettingsViewModel { errorMessage = nil do { - let response = try await sendAndWait("clock sync") + let response = try await sendAndWait( + RemoteCLICommandRewriter.rewrite(RemoteCLICommandRewriter.clockSyncCommand) + ) switch NodeSettingsResponseParser.classifyClockSyncResponse(response) { case .synced: + if NodeSettingsResponseParser.clockResponseText(in: response) != nil { + applyDeviceTime(response) + } else { + clockDrift = nil + } successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced showSuccessAlert = true case .clockAhead: @@ -699,7 +717,7 @@ final class NodeSettingsViewModel { } registerLateRecovery(query: "clock") { [weak self] value in guard let self, case let .deviceTime(time) = value else { return } - deviceTimeUTC = time + applyDeviceTime(time) deviceInfoError = firmwareVersion == nil } } diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift index b5995f11e..8aab99017 100644 --- a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift @@ -38,7 +38,8 @@ enum NeighborSNRMapBuilder { contacts: [ContactDTO], discoveredNodes: [DiscoveredNodeDTO], userLocation: CLLocation?, - filter: MapFilterState + filter: MapFilterState, + keyDisplayByteCount: Int ) -> PlottedNeighbors { let filter = filter.sanitized(for: .neighborSNR) let effectiveContacts: [ContactDTO] = if filter.favoritesOnly { @@ -80,7 +81,10 @@ enum NeighborSNRMapBuilder { ) else { unplottable.append(UnplottableNeighbor( neighbor: neighbor, - displayName: NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix), + displayName: NeighborNameResolver.fallbackName( + for: neighbor.publicKeyPrefix, + byteCount: keyDisplayByteCount + ), matchKind: .unresolved )) continue @@ -136,10 +140,30 @@ enum NeighborSNRMapBuilder { points: points, lines: lines, region: plottedCoordinates.boundingRegion(), - unplottable: unplottable + unplottable: disambiguatingUnresolved(unplottable) ) } + /// Distinct unresolved neighbours can share the clamped key prefix and look identical; + /// colliding titles widen to the full stored prefix. + private static func disambiguatingUnresolved(_ unplottable: [UnplottableNeighbor]) -> [UnplottableNeighbor] { + var titleCounts: [String: Int] = [:] + for item in unplottable where item.matchKind == .unresolved { + titleCounts[item.displayName, default: 0] += 1 + } + guard titleCounts.contains(where: { $0.value > 1 }) else { return unplottable } + return unplottable.map { item in + guard item.matchKind == .unresolved, titleCounts[item.displayName, default: 0] > 1 else { + return item + } + return UnplottableNeighbor( + neighbor: item.neighbor, + displayName: item.neighbor.publicKeyPrefix.uppercaseHexString(), + matchKind: .unresolved + ) + } + } + private static func isPlottable(_ coordinate: CLLocationCoordinate2D) -> Bool { coordinate.isValidFix } diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift index bec65d314..762ad9c3d 100644 --- a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift @@ -21,6 +21,9 @@ struct NeighborSNRMapView: View { let contacts: [ContactDTO] let discoveredNodes: [DiscoveredNodeDTO] let userLocation: CLLocation? + /// Hex width for unresolved identity prefixes. Captured with the neighbours fetch so map + /// titles and no-location rows use the same value. + let keyDisplayByteCount: Int @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels @@ -86,7 +89,10 @@ struct NeighborSNRMapView: View { .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $showingNoLocationList) { if let unplottable = plotted?.unplottable { - NeighborsNoLocationList(unplottable: unplottable) + NeighborsNoLocationList( + unplottable: unplottable, + keyDisplayByteCount: keyDisplayByteCount + ) } } .onAppear { @@ -114,7 +120,8 @@ struct NeighborSNRMapView: View { contacts: contacts, discoveredNodes: discoveredNodes, userLocation: userLocation, - filter: mapFilter + filter: mapFilter, + keyDisplayByteCount: keyDisplayByteCount ) } @@ -185,13 +192,15 @@ struct NeighborSNRMapView: View { /// unresolved). Reuses `NeighborRow`, including its "?" fallback-match affordance. private struct NeighborsNoLocationList: View { let unplottable: [NeighborSNRMapBuilder.UnplottableNeighbor] + let keyDisplayByteCount: Int var body: some View { List(unplottable, id: \.neighbor.publicKeyPrefix) { item in NeighborRow( neighbor: item.neighbor, displayName: item.displayName, - matchKind: item.matchKind + matchKind: item.matchKind, + keyDisplayByteCount: keyDisplayByteCount ) } .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(unplottable.count)) diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift index 92751cc82..9e76de5d1 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift @@ -21,7 +21,6 @@ struct RepeaterSettingsView: View { /// 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? - @State private var clockDrift: TimeInterval? var body: some View { // ZStack, not Group: a stable container keeps the navigation title hosted on one @@ -40,8 +39,7 @@ struct RepeaterSettingsView: View { discoveredNodes: discoveredNodes, userLocation: appState.bestAvailableLocation, connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact, - clockDrift: clockDrift + routePathContact: routeContact ) } } @@ -70,7 +68,6 @@ struct RepeaterSettingsView: View { contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] } - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) await refreshRouteContact() } .onChange(of: appState.contactsVersion) { @@ -87,7 +84,8 @@ struct RepeaterSettingsView: View { statusViewModel.configure( repeaterAdminService: { appState.services?.repeaterAdminService }, contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } + nodeSnapshotService: { appState.services?.nodeSnapshotService }, + deviceHashSize: { appState.connectedDevice?.hashSize } ) Task { await statusViewModel.registerHandlers() @@ -338,6 +336,9 @@ private struct BehaviorSection: View { private struct RegionsSection: View { @Bindable var viewModel: RepeaterSettingsViewModel + @State private var showingAddAlert = false + @State private var newRegionName = "" + @State private var validationMessage: String? /// Regions sorted: wildcard first, then alphabetical private var sortedRegions: [RepeaterRegionEntry] { @@ -355,6 +356,17 @@ private struct RegionsSection: View { : region.name } + private var defaultScopePickerNames: [String] { + var names = sortedRegions.filter { !$0.isWildcard }.map(\.name) + if let current = viewModel.defaultScopeName, + current != RepeaterSettingsViewModel.wildcardName, + !current.isEmpty, + !names.contains(current) { + names.append(current) + } + return names + } + var body: some View { ExpandableSettingsSection( title: L10n.RemoteNodes.RemoteNodes.Settings.regions, @@ -371,24 +383,41 @@ private struct RegionsSection: View { .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) } + if viewModel.defaultScopeLoaded { + Picker( + L10n.RemoteNodes.RemoteNodes.Settings.Regions.defaultScope, + selection: Binding( + get: { viewModel.defaultScopeName }, + set: { newValue in + Task { await viewModel.setDefaultScope(name: newValue) } + } + ) + ) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.noDefault) + .tag(String?.none) + ForEach(defaultScopePickerNames, id: \.self) { name in + Text(name) + .tag(Optional(name)) + } } - )) { - ForEach(sortedRegions) { region in - Text(displayName(for: region)) - .tag(region.name) + .pickerStyle(.menu) + .tint(.primary) + .disabled(viewModel.isLoadingRegions || viewModel.helper.isApplying) + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.defaultScope) + Spacer() + SettingsLoadPlaceholder( + isLoading: viewModel.isLoadingRegions, + hasError: !viewModel.isLoadingRegions + ) } } - .pickerStyle(.menu) - .tint(.primary) + + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.defaultScopeCaption) + .font(.caption) + .foregroundStyle(.secondary) } // Region list with flood toggles @@ -421,7 +450,8 @@ private struct RegionsSection: View { // Add region button Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { - viewModel.isAddingRegion = true + newRegionName = "" + showingAddAlert = true } .disabled(viewModel.helper.isApplying) @@ -438,17 +468,43 @@ private struct RegionsSection: View { } .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) } + + if let error = viewModel.helper.errorMessage { + Text(error) + .foregroundStyle(.orange) + .font(.caption) + } } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $viewModel.newRegionName) + .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $showingAddAlert) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $newRegionName) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion) { - Task { await viewModel.addRegion(name: viewModel.newRegionName) } + if let error = RegionNameValidator.validate(newRegionName, existingRegions: viewModel.regions.map(\.name)) { + validationMessage = validationText(for: error) + Task { showingAddAlert = true } + return + } + validationMessage = nil + let name = newRegionName.trimmingCharacters(in: .whitespaces) + Task { await viewModel.addRegion(name: name) } } Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { - viewModel.newRegionName = "" + validationMessage = nil } + } message: { + if let validationMessage { + Text(validationMessage) + } + } + } + + private func validationText(for error: RegionNameValidator.ValidationError) -> String? { + switch error { + case .empty: nil + case .invalidCharacters: L10n.RemoteNodes.RemoteNodes.Settings.Regions.invalidName + case let .tooLong(maxBytes): L10n.RemoteNodes.RemoteNodes.Settings.Regions.nameTooLong(maxBytes) + case .duplicate: L10n.RemoteNodes.RemoteNodes.Settings.Regions.duplicate } } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift index f51ecdf23..dab7c1870 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift @@ -41,6 +41,12 @@ final class RepeaterSettingsViewModel { // MARK: - Repeater-Only: Region Settings nonisolated static let wildcardName = "*" + /// CLI argument when default scope is unset (`region default `). + private static let firmwareNullToken = "" + /// GET substring. SET replies use `defaultScopeSetReplyMarker`, which also matches this. + private static let defaultScopeReplyMarker = "default scope is" + private static let defaultScopeSetReplyMarker = "default scope is now" + var regions: [RepeaterRegionEntry] = [] private var originalRegions: [RepeaterRegionEntry]? var isLoadingRegions = false @@ -50,9 +56,11 @@ final class RepeaterSettingsViewModel { } var hasUnsavedRegionChanges = false - var isAddingRegion = false - var newRegionName = "" var regionsSaveSuccess = false + /// Unset when nil. Scopes flood traffic this node originates, not which regions it repeats. + var defaultScopeName: String? + /// False until a `region default` reply parses. Distinct from `defaultScopeName == nil`. + var defaultScopeLoaded = false // MARK: - Expansion State (repeater-only sections) @@ -319,6 +327,20 @@ final class RepeaterSettingsViewModel { let parsed = Self.parseRegionTree(treeResponse) regions = parsed originalRegions = parsed + do { + let defaultReply = try await helper.sendAndWait( + "region default", + timeout: .seconds(10), + rawMatching: true + ) + if let parsed = Self.parseDefaultScopeReply(defaultReply) { + applyParsedDefaultScope(parsed) + } else { + logger.warning("Unparsed region default reply: \(defaultReply)") + } + } catch { + logger.warning("Failed to fetch default scope: \(error)") + } } catch { if case RemoteNodeError.timeout = error { regionsError = true @@ -366,6 +388,39 @@ final class RepeaterSettingsViewModel { return entries } + /// Unset (`` or `*`) versus a named region. + enum ParsedDefaultScope: Equatable { + case cleared + case named(String) + } + + /// Nil is an unparsed reply, not an unset scope. + static func parseDefaultScopeReply(_ response: String) -> ParsedDefaultScope? { + let lines = response.split(separator: "\n", omittingEmptySubsequences: true) + guard let last = lines.last else { return nil } + var line = last.trimmingCharacters(in: .whitespaces) + if line.hasPrefix(">") { + line = String(line.dropFirst()).trimmingCharacters(in: .whitespaces) + } + guard line.localizedCaseInsensitiveContains(defaultScopeReplyMarker) else { return nil } + + let tokens = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard let lastToken = tokens.last else { return nil } + // Firmware treats a default of `*` as unset, same as ``. + if lastToken == firmwareNullToken || lastToken == wildcardName { return .cleared } + return .named(lastToken) + } + + private func applyParsedDefaultScope(_ parsed: ParsedDefaultScope) { + switch parsed { + case .cleared: + defaultScopeName = nil + case let .named(name): + defaultScopeName = name + } + defaultScopeLoaded = true + } + func toggleRegionFlood(name: String) async { guard let index = regions.firstIndex(where: { $0.name == name }) else { return } let currentlyAllowed = regions[index].floodAllowed @@ -389,19 +444,24 @@ final class RepeaterSettingsViewModel { helper.isApplying = false } - func setHomeRegion(name: String) async { - let command = "region home \(name)" + func setDefaultScope(name: String?) async { + if name == Self.wildcardName { return } + if name == defaultScopeName { return } + + let argument = name ?? Self.firmwareNullToken + let command = "region default \(argument)" 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) + if response.contains(Self.defaultScopeSetReplyMarker) { + defaultScopeName = name + defaultScopeLoaded = true + if let name, let index = regions.firstIndex(where: { $0.name == name }) { + regions[index].floodAllowed = true } - hasUnsavedRegionChanges = true } else { helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } @@ -431,11 +491,10 @@ final class RepeaterSettingsViewModel { if case .ok = CLIResponse.parse(response) { regions.append(RepeaterRegionEntry( name: trimmed, - floodAllowed: false, + floodAllowed: true, isHome: false )) hasUnsavedRegionChanges = true - newRegionName = "" } else { helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed } @@ -449,12 +508,24 @@ final class RepeaterSettingsViewModel { func removeRegion(name: String) async { helper.isApplying = true helper.errorMessage = nil + let wasDefault = defaultScopeName == name do { let response = try await helper.sendAndWait("region remove \(name)") if case .ok = CLIResponse.parse(response) { regions.removeAll { $0.name == name } hasUnsavedRegionChanges = true + if wasDefault { + let clearReply = try await helper.sendAndWait( + "region default \(Self.firmwareNullToken)", + rawMatching: true + ) + if clearReply.contains(Self.defaultScopeSetReplyMarker) { + defaultScopeName = nil + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } } else if response.contains("not empty") { helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty } else { diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift index 56a122143..422442adb 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift @@ -20,12 +20,10 @@ struct RepeaterStatusContent: View { let connectedDeviceID: UUID? /// Contact whose login route is shown at the bottom; nil hides the route section. var routePathContact: ContactDTO? - /// Clock drift measured at login; nil when no login carried the node's clock. - var clockDrift: TimeInterval? var body: some View { List { - NodeStatusHeaderSection(session: session, clockDrift: clockDrift) + NodeStatusHeaderSection(session: session) StatusSection(viewModel: viewModel, session: session, connectionState: connectionState) NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { await viewModel.requestTelemetry(for: session) @@ -61,7 +59,8 @@ struct RepeaterStatusContent: View { neighbors: viewModel.neighbors, contacts: contacts, discoveredNodes: discoveredNodes, - userLocation: userLocation + userLocation: userLocation, + keyDisplayByteCount: viewModel.neighborKeyDisplayByteCount ) } .themedCanvas(theme) @@ -215,6 +214,7 @@ private struct NeighborsSection: View { neighbor: neighbor, displayName: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, matchKind: resolution?.matchKind ?? .unresolved, + keyDisplayByteCount: viewModel.neighborKeyDisplayByteCount, previousNeighbor: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots?.first { $0.publicKeyPrefix == neighbor.publicKeyPrefix }, @@ -236,7 +236,10 @@ private struct NeighborsSection: View { ) DisappearedNeighborRow( neighbor: old, - displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName(for: old.publicKeyPrefix), + displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName( + for: old.publicKeyPrefix, + byteCount: viewModel.neighborKeyDisplayByteCount + ), matchKind: resolution?.matchKind ?? .unresolved ) } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift index 72df5d6fa..968588927 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift @@ -13,7 +13,6 @@ struct RepeaterStatusView: View { /// 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? - @State private var clockDrift: TimeInterval? var body: some View { NavigationStack { @@ -25,8 +24,7 @@ struct RepeaterStatusView: View { discoveredNodes: discoveredNodes, userLocation: appState.bestAvailableLocation, connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact, - clockDrift: clockDrift + routePathContact: routeContact ) .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.title) .navigationBarTitleDisplayMode(.inline) @@ -51,7 +49,8 @@ struct RepeaterStatusView: View { viewModel.configure( repeaterAdminService: { appState.services?.repeaterAdminService }, contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } + nodeSnapshotService: { appState.services?.nodeSnapshotService }, + deviceHashSize: { appState.connectedDevice?.hashSize } ) await viewModel.registerHandlers() @@ -64,7 +63,6 @@ struct RepeaterStatusView: View { } } await refreshRouteContact() - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) } .onChange(of: appState.contactsVersion) { Task { await refreshRouteContact() } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift index 21c5bbaa7..cf5418d8c 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift @@ -29,6 +29,10 @@ final class RepeaterStatusViewModel { /// Error scoped to the neighbors section, kept separate from other sections' errors. var neighborsSectionError: String? + /// Hex width for neighbour identity prefixes. Set with each neighbours response so on-screen + /// identifiers keep their width if the companion disconnects while the list is shown. + var neighborKeyDisplayByteCount = NeighborNameResolver.minimumKeyDisplayByteCount + /// Discovery state var isDiscovering: Bool { discoverTask != nil @@ -64,6 +68,11 @@ final class RepeaterStatusViewModel { repeaterAdminServiceProvider() } + private var deviceHashSizeProvider: @MainActor () -> Int? = { nil } + private var deviceHashSize: Int? { + deviceHashSizeProvider() + } + // MARK: - Initialization init() {} @@ -72,9 +81,11 @@ final class RepeaterStatusViewModel { func configure( repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService?, + deviceHashSize: @escaping @MainActor () -> Int? ) { repeaterAdminServiceProvider = repeaterAdminService + deviceHashSizeProvider = deviceHashSize helper.configure( contactService: contactService, nodeSnapshotService: nodeSnapshotService @@ -163,6 +174,7 @@ final class RepeaterStatusViewModel { func handleNeighboursResponse(_ response: NeighboursResponse) async { neighbors = response.neighbours + neighborKeyDisplayByteCount = NeighborNameResolver.keyDisplayByteCount(deviceHashSize: deviceHashSize) isLoadingNeighbors = false neighborsLoaded = true diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 73a9d3eae..af6d4cdea 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -1,5 +1,6 @@ import MC1Services import SwiftUI +import Translation import UIKit /// Full room chat interface @@ -8,6 +9,7 @@ struct RoomConversationView: View { @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase @Environment(\.appTheme) private var theme + @Environment(\.locale) private var locale @State private var session: RemoteNodeSessionDTO @State private var viewModel = RoomConversationViewModel() @@ -20,6 +22,9 @@ struct RoomConversationView: View { @State private var isAtBottom = true @State private var unreadCount = 0 @State private var scrollToBottomRequest = 0 + @State private var translationConfiguration: TranslationSession.Configuration? + @State private var systemTranslationText = "" + @State private var showSystemTranslation = false @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote @@ -81,7 +86,10 @@ struct RoomConversationView: View { .sheet(item: $selectedRoomMessage) { message in RoomMessageActionsSheet( message: message, - availability: RoomMessageActionAvailability(message: message, session: session), + availability: RoomMessageActionAvailability( + message: message, + session: session + ), onAction: { dispatch($0, for: message) } ) } @@ -125,8 +133,35 @@ struct RoomConversationView: View { conversation: nil ) await chatViewModel.loadAllContacts(radioID: session.radioID) + viewModel.applyPreferredLanguageCode(EnvInputs.preferredLanguageCode(from: locale)) await viewModel.loadMessages(for: session) } + .conversationTranslationSession( + configuration: $translationConfiguration, + request: $viewModel.translationSessionRequest, + perform: { translator, request in + let result = await viewModel.performPendingTranslation(using: translator, for: request) + if case let .presentSystemOverlay(text: text) = result { + systemTranslationText = text + showSystemTranslation = true + } + return result + } + ) + // Overlay after `.conversationTranslationSession`: `.translationPresentation` + // must not sit in that modifier's `.translationTask` content tree. + .background { + Color.clear + .accessibilityHidden(true) + .translationPresentation( + isPresented: $showSystemTranslation, + text: systemTranslationText + ) + } + .onChange(of: locale) { _, newLocale in + viewModel.applyPreferredLanguageCode(EnvInputs.preferredLanguageCode(from: newLocale)) + } + .errorAlert($viewModel.errorMessage) .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. @@ -183,6 +218,7 @@ struct RoomConversationView: View { } } .onDisappear { + viewModel.cancelPendingTranslation() // 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 { @@ -207,8 +243,9 @@ struct RoomConversationView: View { private func makeMessagesView() -> some View { MessagesView( + viewModel: viewModel, hasLoadedOnce: viewModel.hasLoadedOnce, - messages: viewModel.messages, + tiledRows: viewModel.tiledRows, isAtBottom: $isAtBottom, unreadCount: $unreadCount, scrollToBottomRequest: scrollToBottomRequest, @@ -217,7 +254,8 @@ struct RoomConversationView: View { onRetry: { id in Task { await viewModel.retryMessage(id: id) } }, - onLongPress: { selectedRoomMessage = $0 } + onLongPress: { selectedRoomMessage = $0 }, + onTranslationAction: { viewModel.performTranslationAction(for: $0) } ) } @@ -263,7 +301,9 @@ extension RoomConversationView { private func dispatch(_ action: RoomMessageAction, for message: RoomMessageDTO) { switch action { case .copy: - UIPasteboard.general.string = message.text + UIPasteboard.general.string = viewModel.displayedText(for: message) + case .translate: + presentSystemTranslation(text: message.text) case .reply: handleReply(for: message) case .sendDM: @@ -273,6 +313,14 @@ extension RoomConversationView { } } + private func presentSystemTranslation(text: String) { + systemTranslationText = text + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + showSystemTranslation = true + } + } + private func handleReply(for message: RoomMessageDTO) { if replyWithQuote { viewModel.composingText = MentionUtilities.buildReplyText( @@ -307,8 +355,9 @@ extension RoomConversationView { // MARK: - Messages View private struct MessagesView: View { + var viewModel: RoomConversationViewModel let hasLoadedOnce: Bool - let messages: [RoomMessageDTO] + let tiledRows: [RoomTiledRow] @Binding var isAtBottom: Bool @Binding var unreadCount: Int let scrollToBottomRequest: Int @@ -316,55 +365,67 @@ private struct MessagesView: View { let theme: Theme let onRetry: (UUID) -> Void let onLongPress: (RoomMessageDTO) -> Void + let onTranslationAction: (UUID) -> Void @Environment(\.openURL) private var openURL + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var incomingAvatarFlight = IncomingAvatarFlight() var body: some View { Group { if !hasLoadedOnce { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if messages.isEmpty { + } else if tiledRows.isEmpty { EmptyMessagesView(session: session) } else { - let timestampVisibleIDs = Self.timestampVisibleIDs(in: messages) ChatTiledView( - items: messages, - cellContent: { message in - messageBubble(for: message, showTimestamp: timestampVisibleIDs.contains(message.id)) + items: tiledRows, + cellContent: { row in + messageBubble(for: row) .environment(\.appTheme, theme) .environment(\.openURL, openURL) + .environment(\.incomingAvatarFlight, incomingAvatarFlight) }, contentBackground: theme.surfaces?.canvas, isAtBottom: $isAtBottom, unreadCount: $unreadCount, - scrollToBottomRequest: scrollToBottomRequest + scrollToBottomRequest: scrollToBottomRequest, + countsTowardUnread: { !$0.message.isFromSelf } ) + .overlay { + incomingAvatarFlight.overlay() + } + .onAppear { + viewModel.incomingAvatarFlight = incomingAvatarFlight + incomingAvatarFlight.isAtBottom = isAtBottom + incomingAvatarFlight.reduceMotion = reduceMotion + } + .onChange(of: isAtBottom, initial: true) { _, atBottom in + incomingAvatarFlight.isAtBottom = atBottom + } + .onChange(of: reduceMotion, initial: true) { _, reduce in + incomingAvatarFlight.reduceMotion = reduce + } } } .themedCanvas(theme) } - private func messageBubble(for message: RoomMessageDTO, showTimestamp: Bool) -> some View { + private func messageBubble(for row: RoomTiledRow) -> some View { RoomMessageBubble( - message: message, - showTimestamp: showTimestamp, - onRetry: message.status == .failed ? { - onRetry(message.id) + message: row.message, + showTimestamp: row.showTimestamp, + showSenderName: row.showSenderName, + showAvatar: row.showAvatar, + translation: row.translation, + onRetry: row.message.status == .failed ? { + onRetry(row.message.id) } : nil, - onLongPress: onLongPress + onLongPress: onLongPress, + onTranslationAction: { onTranslationAction(row.message.id) } ) } - - /// 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 diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift new file mode 100644 index 000000000..bdd76f0b3 --- /dev/null +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift @@ -0,0 +1,177 @@ +import Foundation +import MC1Services + +extension RoomConversationViewModel { + func applyPreferredLanguageCode(_ code: String) { + guard !MessageLanguageDetector.isSameLanguage(preferredLanguageCode, code) else { return } + invalidateTranslations(preferredLanguageCode: code) + preferredLanguageCode = code + refreshTiledRows() + } + + func performTranslationAction(for messageID: UUID) { + if case .showing = translationPhases[messageID] { + translationPhases[messageID] = .offer + refreshTiledRows() + return + } + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode(preferredLanguageCode) + if let cached = translationCache[messageID]?[targetLanguageCode] { + resetInProgressTranslations() + translationSessionRequest = nil + translationPhases[messageID] = .showing( + translatedText: cached, + targetLanguageCode: targetLanguageCode + ) + refreshTiledRows() + return + } + if case .inProgress = translationPhases[messageID] { + return + } + resetInProgressTranslations() + guard let sourceLanguageCode = detectedLanguages[messageID]?.code else { + return + } + translationPhases[messageID] = .inProgress + refreshTiledRows() + translationGeneration += 1 + translationSessionRequest = TranslationSessionRequest( + messageID: messageID, + sourceLanguageCode: sourceLanguageCode, + targetLanguageCode: targetLanguageCode, + generation: translationGeneration + ) + } + + func performPendingTranslation( + using translator: any MessageTranslating, + for request: TranslationSessionRequest + ) async -> TranslationPerformResult { + guard isCurrent(request) else { return .finished } + let capturedID = request.messageID + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode( + request.targetLanguageCode + ) + guard let text = messages.first(where: { $0.id == capturedID })?.text else { + guard isCurrent(request) else { return .finished } + translationPhases[capturedID] = .offer + translationSessionRequest = nil + refreshTiledRows() + return .finished + } + + do { + let result = try await translator.translate(text) + guard isCurrent(request) else { return .finished } + var perTarget = translationCache[capturedID] ?? [:] + perTarget[targetLanguageCode] = result + translationCache[capturedID] = perTarget + translationPhases[capturedID] = .showing( + translatedText: result, + targetLanguageCode: targetLanguageCode + ) + translationSessionRequest = nil + refreshTiledRows() + return .finished + } catch is MessageTranslationNeedsDownloadError { + guard isCurrent(request) else { return .finished } + return .needsDownload + } catch { + guard isCurrent(request) else { return .finished } + translationPhases[capturedID] = .offer + translationSessionRequest = nil + refreshTiledRows() + if isQuietTranslationCancel(error) { + return .finished + } + return .presentSystemOverlay(text: text) + } + } + + func cancelPendingTranslation() { + translationGeneration += 1 + translationSessionRequest = nil + let idsToReset = translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + for id in idsToReset { + translationPhases[id] = .offer + } + refreshTiledRows() + } + + func invalidateTranslations(preferredLanguageCode: String) { + translationGeneration += 1 + translationSessionRequest = nil + let preferred = MessageLanguageDetector.collapsedLanguageCode(preferredLanguageCode) + for (id, phase) in translationPhases { + switch phase { + case .inProgress: + translationPhases[id] = .offer + case let .showing(_, target) where !MessageLanguageDetector.isSameLanguage(target, preferred): + translationPhases[id] = .offer + case .offer, .showing: + break + } + } + } + + func displayedText(for message: RoomMessageDTO) -> String { + if case let .showing(translatedText, _) = translation(for: message.id)?.phase { + return translatedText + } + return message.text + } + + func translation(for messageID: UUID) -> MessageTranslationChrome? { + tiledRows.first { $0.id == messageID }?.translation + } + + func refreshTiledRows() { + seedDetectedLanguages() + var translations: [UUID: MessageTranslationChrome] = [:] + for message in messages { + guard !message.isFromSelf else { continue } + if let chrome = MessageTranslationChrome.resolved( + detected: detectedLanguages[message.id], + phase: translationPhases[message.id], + preferredLanguageCode: preferredLanguageCode + ) { + translations[message.id] = chrome + } + } + tiledRows = Self.tiledRows(in: messages, translations: translations) + } + + private func seedDetectedLanguages() { + for message in messages { + guard !message.isFromSelf else { continue } + guard detectedLanguages[message.id] == nil else { continue } + detectedLanguages[message.id] = MessageLanguageDetector.dominantLanguage(for: message.text) + } + } + + private func resetInProgressTranslations() { + let inProgressIDs = translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + guard !inProgressIDs.isEmpty else { return } + translationGeneration += 1 + for id in inProgressIDs { + translationPhases[id] = .offer + } + refreshTiledRows() + } + + private func isCurrent(_ request: TranslationSessionRequest) -> Bool { + translationSessionRequest?.generation == request.generation + } + + /// `CancellationError`, or `CocoaError.userCancelled` bridged as NSError. + private func isQuietTranslationCancel(_ error: Error) -> Bool { + if error is CancellationError { return true } + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } +} diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 5ffa7b78b..19ce848fa 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -1,10 +1,13 @@ import MC1Services +import OSLog import SwiftUI /// ViewModel for room conversation operations @Observable @MainActor final class RoomConversationViewModel { + private let logger = Logger(subsystem: "com.mc1", category: "RoomConversationViewModel") + // MARK: - Properties /// Current room session @@ -13,6 +16,30 @@ final class RoomConversationViewModel { /// Room messages var messages: [RoomMessageDTO] = [] + /// Materialized tiled rows. Chrome including Translation offer lives here + /// so `ChatTiledView` reconfigures when a phase changes. + var tiledRows: [RoomTiledRow] = [] + + /// Pending Translation session request. Observed so the view can invalidate + /// `TranslationSession.Configuration`. + var translationSessionRequest: TranslationSessionRequest? + + var preferredLanguageCode: String = EnvInputs.defaultPreferredLanguageCode + + /// Monotonic generation for in-flight translation. Apply a result only + /// when it still matches `translationSessionRequest.generation`. + @ObservationIgnored var translationGeneration: UInt64 = 0 + + /// Detected language keyed by message id. Missing key = not yet run; + /// `.undetermined` = ran, no code. + var detectedLanguages: [UUID: DetectedLanguage] = [:] + /// Per-message Translation offer phase. Missing lets + /// `MessageTranslationChrome.resolved` decide from detection. + var translationPhases: [UUID: MessageTranslationChrome.Phase] = [:] + /// Last successful translation per message, keyed by collapsed target + /// language code. A DE→EN result must not be reused as DE→FR. + var translationCache: [UUID: [String: String]] = [:] + /// Loading state var isLoading = false @@ -60,6 +87,10 @@ final class RoomConversationViewModel { /// state still feels fresh. private static let reloadDebounce: Duration = .milliseconds(50) + /// View-owned drop animation. Weak so a disappeared room cannot start a + /// flight after its overlay is gone. + @ObservationIgnored weak var incomingAvatarFlight: IncomingAvatarFlight? + // MARK: - Initialization init() {} @@ -91,10 +122,12 @@ final class RoomConversationViewModel { do { messages = try await roomServerService.fetchMessages(sessionID: session.id) + refreshTiledRows() // 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) + try await dataStore?.markFailedSendsSeen(roomSessionID: session.id) await notificationService?.removeDeliveredNotifications(forRoomSessionID: session.id) await notificationService?.updateBadgeCount() syncCoordinator?.notifyConversationsChanged() @@ -113,8 +146,20 @@ final class RoomConversationViewModel { /// `[timestamp, createdAt]` sort. func appendMessageIfNew(_ message: RoomMessageDTO) { guard !messages.contains(where: { $0.id == message.id }) else { return } + let previousTail = messages.last let index = messages.firstIndex { $0.timestamp > message.timestamp } ?? messages.endIndex + let isTailAppend = index == messages.endIndex messages.insert(message, at: index) + refreshTiledRows() + if isTailAppend, + let previous = previousTail, + Self.incomingClusterContinues(from: previous, to: message) { + incomingAvatarFlight?.beginFlight( + from: previous.id, + to: message.id, + identity: .initials(name: message.authorDisplayName) + ) + } } /// Send a message to the current room @@ -172,11 +217,17 @@ final class RoomConversationViewModel { case let .roomMessageFailed(messageID): if messages.contains(where: { $0.id == messageID }) { scheduleCoalescedReload() + do { + try await dataStore?.markFailedSendsSeen(roomSessionID: session.id) + syncCoordinator?.notifyConversationsChanged() + } catch { + logger.warning("Failed to mark in-thread room failed send seen: \(error.localizedDescription)") + } } case .directMessageReceived, .channelMessageReceived, .messageStatusResolved, .messageResent, .messageFailed, .messageRetrying, - .heardRepeatRecorded, .reactionReceived, .routingChanged: + .heardRepeatRecorded, .reactionReceived, .messagesRegionUpdated, .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. @@ -207,6 +258,7 @@ final class RoomConversationViewModel { // Update local array if let index = messages.firstIndex(where: { $0.id == id }) { messages[index] = updatedMessage + refreshTiledRows() } } catch { errorMessage = error.userFacingMessage @@ -229,4 +281,45 @@ final class RoomConversationViewModel { let gap = abs(Int(currentMessage.timestamp) - Int(previousMessage.timestamp)) return gap > messageGroupingGapSeconds } + + /// Incoming rooms cluster on `authorKeyPrefix` plus `messageGroupingGapSeconds`. + /// Display-name matches do not merge prefixes. + static func incomingClusterContinues(from earlier: RoomMessageDTO, to later: RoomMessageDTO) -> Bool { + guard !earlier.isFromSelf, !later.isFromSelf else { return false } + let gap = abs(Int(later.timestamp) - Int(earlier.timestamp)) + guard gap <= messageGroupingGapSeconds else { return false } + return earlier.authorKeyPrefix == later.authorKeyPrefix + } + + /// Name on cluster-start, avatar on cluster-end. + static func incomingBookends(in messages: [RoomMessageDTO]) -> (nameIDs: Set, avatarIDs: Set) { + var nameIDs: Set = [] + var avatarIDs: Set = [] + for (index, message) in messages.enumerated() { + guard !message.isFromSelf else { continue } + let previous = index > 0 ? messages[index - 1] : nil + let next = index + 1 < messages.count ? messages[index + 1] : nil + let continuesFromPrevious = previous.map { incomingClusterContinues(from: $0, to: message) } ?? false + let continuesToNext = next.map { incomingClusterContinues(from: message, to: $0) } ?? false + if !continuesFromPrevious { nameIDs.insert(message.id) } + if !continuesToNext { avatarIDs.insert(message.id) } + } + return (nameIDs, avatarIDs) + } + + static func tiledRows( + in messages: [RoomMessageDTO], + translations: [UUID: MessageTranslationChrome] = [:] + ) -> [RoomTiledRow] { + let bookends = incomingBookends(in: messages) + return messages.enumerated().map { index, message in + RoomTiledRow( + message: message, + showTimestamp: shouldShowTimestamp(at: index, in: messages), + showSenderName: bookends.nameIDs.contains(message.id), + showAvatar: bookends.avatarIDs.contains(message.id), + translation: translations[message.id] + ) + } + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift index 228af92b1..7763db34f 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift @@ -1,6 +1,7 @@ /// An action a user can take on a room message from its long-press sheet. enum RoomMessageAction { case copy + case translate case reply case sendDM case sendAgain diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift index 7d884bea3..b03840fdd 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift @@ -8,7 +8,10 @@ struct RoomMessageActionAvailability { let canSendDM: Bool let canSendAgain: Bool - init(message: RoomMessageDTO, session: RemoteNodeSessionDTO) { + 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 76eff048e..1cb1a9de7 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift @@ -86,6 +86,12 @@ struct RoomMessageActionsSheet: View { action: { performAction(.copy) } ) + ActionButton( + title: L10n.Chats.Chats.Message.Action.translate, + icon: "translate", + action: { performAction(.translate) } + ) + if availability.canSendAgain { ActionButton( title: L10n.Chats.Chats.Message.Action.sendAgain, diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift index 75e089ac3..5bcfa378c 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift @@ -5,10 +5,17 @@ import SwiftUI struct RoomMessageBubble: View { let message: RoomMessageDTO let showTimestamp: Bool + let showSenderName: Bool + let showAvatar: Bool + var translation: MessageTranslationChrome? var onRetry: (() -> Void)? var onLongPress: ((RoomMessageDTO) -> Void)? + var onTranslationAction: (() -> Void)? @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.openURL) private var openURL @State private var isLongPressing = false @State private var longPressTrigger = 0 @@ -32,6 +39,33 @@ struct RoomMessageBubble: View { makeBubbleContent() makeStatusIndicator() } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityMessageLabel) + .accessibilityAction { + onLongPress?(message) + } + .accessibilityActions { + if let chrome = translation { + switch chrome.phase { + case .offer: + Button(L10n.Chats.Chats.Message.Action.translate) { + onTranslationAction?() + } + case .showing: + Button(L10n.Chats.Chats.Message.Action.showOriginal) { + onTranslationAction?() + } + case .inProgress: + EmptyView() + } + } + if accessibilityShowsRetryAction { + Button(L10n.Chats.Chats.Message.Action.retry) { performAccessibilityRetry() } + } + ForEach(accessibilityLinkActions(formatted: formattedBodyText)) { action in + Button(action.name) { openURL(action.url) } + } + } if !isFromSelf { Spacer(minLength: 60) @@ -47,11 +81,59 @@ struct RoomMessageBubble: View { TimestampView(date: message.date) } + var accessibilityMessageLabel: String { + let body: String = switch translation?.phase { + case let .showing(translated, _): + L10n.Chats.Chats.Message.translatedAccessibility(translated) + case .inProgress: + "\(message.text), \(L10n.Chats.Chats.Message.Action.translating)" + default: + message.text + } + if isFromSelf { + return "\(body), \(message.accessibilityStatusLabel)" + } + return "\(message.authorDisplayName): \(body)" + } + + var accessibilityShowsRetryAction: Bool { + message.status == .failed && onRetry != nil + } + + func performAccessibilityRetry() { + onRetry?() + } + + func accessibilityLinkActions(formatted: AttributedString) -> [MessageLinkAccessibility.Action] { + MessageLinkAccessibility.actions(previewURL: nil, formatted: formatted) + } + + private var formattedBodyText: AttributedString { + MessageText.buildFormattedText( + text: message.text, + isOutgoing: isFromSelf, + currentUserName: nil, + isHighContrast: colorSchemeContrast == .increased, + outgoingTextColor: theme.outgoingTextColor, + hashtagColor: theme.hashtagColor, + identityGamut: theme.identityGamut, + identityBackgroundLuminances: theme.avatarSurfaceLuminances( + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + ).text + } + private func makeBubbleContent() -> some View { BubbleContent( message: message, isFromSelf: isFromSelf, - highContrast: colorSchemeContrast == .increased + showSenderName: showSenderName, + showAvatar: showAvatar, + highContrast: colorSchemeContrast == .increased, + formattedBodyText: formattedBodyText, + translation: translation, + onTranslationAction: onTranslationAction ) .messageBubbleLongPressGesture( isPressing: $isLongPressing, @@ -90,7 +172,12 @@ private struct TimestampView: View { private struct BubbleContent: View { let message: RoomMessageDTO let isFromSelf: Bool + let showSenderName: Bool + let showAvatar: Bool let highContrast: Bool + let formattedBodyText: AttributedString + var translation: MessageTranslationChrome? + var onTranslationAction: (() -> Void)? @Environment(\.appTheme) private var theme @Environment(\.colorScheme) private var colorScheme @@ -111,7 +198,7 @@ private struct BubbleContent: View { var body: some View { VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 0) { - if !isFromSelf { + if !isFromSelf, showSenderName { Text(message.authorDisplayName) .font(.footnote) .bold() @@ -121,13 +208,44 @@ private struct BubbleContent: View { contrast: highContrast ? .increased : .standard )) .senderNamePlacement() + .padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) + .accessibilityHidden(true) + } + + IncomingAvatarGutter( + identity: showAvatar ? .initials(name: message.authorDisplayName) : nil, + reserveColumn: !isFromSelf, + messageID: message.id + ) { + messageBox + } + } + } + + private var messageBox: some View { + VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 2) { + if let chrome = translation { + BubbleTranslationControl( + phase: chrome.phase, + onTap: { onTranslationAction?() } + ) } + messageBody + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16, style: .continuous)) + } - MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(bubbleBackground) - .clipShape(.rect(cornerRadius: 16, style: .continuous)) + @ViewBuilder + private var messageBody: some View { + if case let .showing(translated, _) = translation?.phase { + Text(translated) + .font(.body) + .foregroundStyle(textColor) + } else { + MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf, precomputedText: formattedBodyText) } } } @@ -187,7 +305,9 @@ private struct StatusIndicator: View { timestamp: UInt32(Date().timeIntervalSince1970), isFromSelf: true ), - showTimestamp: true + showTimestamp: true, + showSenderName: false, + showAvatar: false ) } @@ -201,7 +321,9 @@ private struct StatusIndicator: View { timestamp: UInt32(Date().timeIntervalSince1970), isFromSelf: false ), - showTimestamp: true + showTimestamp: true, + showSenderName: true, + showAvatar: true ) } @@ -216,7 +338,9 @@ private struct StatusIndicator: View { isFromSelf: true, status: .pending ), - showTimestamp: true + showTimestamp: true, + showSenderName: false, + showAvatar: false ) } @@ -232,6 +356,8 @@ private struct StatusIndicator: View { status: .failed ), showTimestamp: true, + showSenderName: false, + showAvatar: false, onRetry: { print("Retry tapped") } ) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift index 33ea176e4..3d353b166 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift @@ -15,7 +15,6 @@ struct RoomSettingsView: View { @State private var showRebootConfirmation = false @State private var showingLocationPicker = false @State private var telemetryConfigured = false - @State private var clockDrift: TimeInterval? var body: some View { // ZStack, not Group: a stable container keeps the navigation title hosted on one @@ -30,8 +29,7 @@ struct RoomSettingsView: View { viewModel: statusViewModel, session: session, connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID, - clockDrift: clockDrift + connectedDeviceID: appState.connectedDevice?.radioID ) } } @@ -53,7 +51,6 @@ struct RoomSettingsView: View { if let send = viewModel.makeNodeCLISendClosure(session: session) { cliViewModel.configure(sessionName: session.name, sendRawCommand: send) } - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) } .onChange(of: managementTab) { _, newTab in guard newTab == .telemetry, !telemetryConfigured else { return } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift index 6cb52d25a..bbe121bee 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift @@ -13,12 +13,10 @@ struct RoomStatusContent: View { let session: RemoteNodeSessionDTO let connectionState: DeviceConnectionState let connectedDeviceID: UUID? - /// Clock drift measured at login; nil when no login carried the node's clock. - var clockDrift: TimeInterval? var body: some View { List { - NodeStatusHeaderSection(session: session, clockDrift: clockDrift) + NodeStatusHeaderSection(session: session) RoomStatusSection(viewModel: viewModel, session: session, connectionState: connectionState) NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { await viewModel.requestTelemetry(for: session) diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift index 903e4f516..d7fb522a2 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift @@ -8,7 +8,6 @@ struct RoomStatusView: View { let session: RemoteNodeSessionDTO @State private var viewModel = RoomStatusViewModel() - @State private var clockDrift: TimeInterval? var body: some View { NavigationStack { @@ -16,8 +15,7 @@ struct RoomStatusView: View { viewModel: viewModel, session: session, connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID, - clockDrift: clockDrift + connectedDeviceID: appState.connectedDevice?.radioID ) .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) .navigationBarTitleDisplayMode(.inline) @@ -50,7 +48,6 @@ struct RoomStatusView: View { if let radioID = appState.connectedDevice?.radioID { await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) } - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) } } .onDisappear { diff --git a/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift b/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift new file mode 100644 index 000000000..2bd5d2447 --- /dev/null +++ b/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift @@ -0,0 +1,16 @@ +import Foundation +import MC1Services + +/// Tiled room item. Chrome flags live here so `ChatTiledView` reconfigures +/// when a cluster follow-up hides the previous avatar (DTO equality would not). +struct RoomTiledRow: Identifiable, Hashable, Sendable { + let message: RoomMessageDTO + let showTimestamp: Bool + let showSenderName: Bool + let showAvatar: Bool + let translation: MessageTranslationChrome? + + var id: UUID { + message.id + } +} diff --git a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift index 409e32d0d..ff916ae2d 100644 --- a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift @@ -47,6 +47,9 @@ struct NodeSettingsHeaderSection: View { // MARK: - Device Info Section struct NodeDeviceInfoSection: View { + /// Clock drift below this magnitude is normal RTC scatter and not shown. + private static let clockDriftWarningThreshold: TimeInterval = 300 + @Bindable var settings: NodeSettingsViewModel var body: some View { @@ -62,8 +65,24 @@ struct NodeDeviceInfoSection: View { ) { LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? NodeStatusViewModel.emDash) LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? NodeStatusViewModel.emDash) + if let drift = settings.clockDrift, abs(drift) >= Self.clockDriftWarningThreshold { + Label(clockDriftWarning(drift), systemImage: "clock.badge.exclamationmark") + .font(.footnote) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } } } + + private func clockDriftWarning(_ drift: TimeInterval) -> String { + let magnitude = Duration.seconds(abs(drift)).formatted( + .units(allowed: [.days, .hours, .minutes, .seconds], width: .abbreviated, maximumUnitCount: 2) + ) + return drift > 0 + ? L10n.RemoteNodes.RemoteNodes.Status.clockAhead(magnitude) + : L10n.RemoteNodes.RemoteNodes.Status.clockBehind(magnitude) + } } // MARK: - Radio Settings Section @@ -311,7 +330,6 @@ struct RemoteNodeIdentitySection: View { struct NodeContactInfoSection: View { @Bindable var settings: NodeSettingsViewModel var focusedField: FocusState.Binding - @State private var contactText = "" var body: some View { ExpandableSettingsSection( @@ -324,21 +342,47 @@ struct NodeContactInfoSection: View { 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) { + Fields(settings: settings, focusedField: focusedField) + } + } + + /// Separate view so `errorMessage` invalidates these rows, not only the + /// stored ExpandableSettingsSection content closure. + private struct Fields: View { + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + + var body: some View { + if settings.ownerInfo != nil { + VStack(alignment: .leading, spacing: 4) { + TextField( + L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, + text: Binding( + get: { settings.ownerInfo ?? "" }, + set: { settings.ownerInfo = $0 } + ), + axis: .vertical + ) + .lineLimit(3...6) + .focused(focusedField, equals: .contactInfo) + 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 + .frame(maxWidth: .infinity, alignment: .trailing) } + } else { + SettingsLoadPlaceholder( + isLoading: settings.isLoadingContactInfo, + hasError: settings.contactInfoError + ) + } + + if let applyError = settings.errorMessage { + Text(applyError) + .foregroundStyle(.orange) + .font(.caption) + } Button { Task { await settings.applyContactInfoSettings() } diff --git a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift index ebb3e9b5d..0be1197ec 100644 --- a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift @@ -4,11 +4,7 @@ import SwiftUI // MARK: - Status Header struct NodeStatusHeaderSection: View { - /// Clock drift below this magnitude is normal RTC scatter and not shown. - private static let clockDriftWarningThreshold: TimeInterval = 300 - let session: RemoteNodeSessionDTO - var clockDrift: TimeInterval? var body: some View { Section { @@ -25,13 +21,6 @@ struct NodeStatusHeaderSection: View { .font(.subheadline) .foregroundStyle(.secondary) } - - if let drift = clockDrift, abs(drift) >= Self.clockDriftWarningThreshold { - Label(clockDriftWarning(drift), systemImage: "clock.badge.exclamationmark") - .font(.footnote) - .foregroundStyle(.orange) - .multilineTextAlignment(.center) - } } Spacer() } @@ -40,15 +29,6 @@ struct NodeStatusHeaderSection: View { } .listSectionSpacing(.compact) } - - private func clockDriftWarning(_ drift: TimeInterval) -> String { - let magnitude = Duration.seconds(abs(drift)).formatted( - .units(allowed: [.days, .hours, .minutes, .seconds], width: .abbreviated, maximumUnitCount: 2) - ) - return drift > 0 - ? L10n.RemoteNodes.RemoteNodes.Status.clockAhead(magnitude) - : L10n.RemoteNodes.RemoteNodes.Status.clockBehind(magnitude) - } } // MARK: - Common Status Rows diff --git a/MC1/Views/Settings/AppBackupViewModel.swift b/MC1/Views/Settings/AppBackupViewModel.swift index e91768ec9..bdb09dcbb 100644 --- a/MC1/Views/Settings/AppBackupViewModel.swift +++ b/MC1/Views/Settings/AppBackupViewModel.swift @@ -230,13 +230,13 @@ final class AppBackupViewModel { 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. + // Import runs in one store-actor turn. Per-operation saves leave no + // foreign pending writes, so rollback can only discard this import. guard !connectionManager.connectionState.isConnected else { dismissImportSheet() return } - let store = connectionManager.createStandalonePersistenceStore() + let store = preferredPersistenceStore() isCancellingImport = false importState = .importing @@ -314,9 +314,7 @@ final class AppBackupViewModel { } 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() + connectionManager.persistenceStore } private func isUserCancelled(_ error: any Error) -> Bool { diff --git a/MC1/Views/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index e2643fba0..b2d47ca36 100644 --- a/MC1/Views/Settings/Sections/DiagnosticsSection.swift +++ b/MC1/Views/Settings/Sections/DiagnosticsSection.swift @@ -49,7 +49,7 @@ struct DiagnosticsSection: View { } private func exportLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + let dataStore = appState.connectionManager.persistenceStore isExporting = true Task { @MainActor in @@ -66,7 +66,7 @@ struct DiagnosticsSection: View { } private func clearDebugLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + let dataStore = appState.connectionManager.persistenceStore Task { do { diff --git a/MC1/Views/Settings/Sections/WiFiEditSheet.swift b/MC1/Views/Settings/Sections/WiFiEditSheet.swift index e6453faf8..7cc5892e2 100644 --- a/MC1/Views/Settings/Sections/WiFiEditSheet.swift +++ b/MC1/Views/Settings/Sections/WiFiEditSheet.swift @@ -36,12 +36,12 @@ struct WiFiEditSheet: View { } private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + WiFiAddressFields.isValidHost(ipAddress) && WiFiAddressFields.isValidPort(port) } private var hasChanges: Bool { guard let host = originalHost, let currentPort = originalPort else { return true } - return ipAddress != host || port != String(currentPort) + return WiFiAddressFields.normalizedHost(ipAddress) != host || port != String(currentPort) } var body: some View { @@ -120,7 +120,11 @@ struct WiFiEditSheet: View { 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) + try await appState.connectViaWiFi( + host: WiFiAddressFields.normalizedHost(ipAddress), + port: portNumber, + forceFullSync: true + ) dismiss() } catch { errorMessage = error.userFacingMessage diff --git a/MC1/Views/Settings/SettingsSubpage.swift b/MC1/Views/Settings/SettingsSubpage.swift index c504d42c0..53fd1b22a 100644 --- a/MC1/Views/Settings/SettingsSubpage.swift +++ b/MC1/Views/Settings/SettingsSubpage.swift @@ -15,8 +15,7 @@ enum SettingsSubpage: Hashable { 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`). + /// hosts the page (the compact Settings stack and the iPad detail column). @MainActor func settingsSubpageDestinations() -> some View { navigationDestination(for: SettingsSubpage.self) { subpage in diff --git a/MC1/Views/Tools/CLI/CLICompletionEngine.swift b/MC1/Views/Tools/CLI/CLICompletionEngine.swift index f733d25e5..375d9f01b 100644 --- a/MC1/Views/Tools/CLI/CLICompletionEngine.swift +++ b/MC1/Views/Tools/CLI/CLICompletionEngine.swift @@ -58,7 +58,7 @@ final class CLICompletionEngine { /// Per MeshCore CLI Reference - region subcommands private static let regionSubcommands = [ - "load", "get", "put", "remove", "allowf", "denyf", "home", "save", "list" + "load", "get", "put", "remove", "allowf", "denyf", "home", "default", "save", "list" ] /// Per MeshCore CLI Reference - gps subcommands diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift index e49878eec..1010e2f3a 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift @@ -614,6 +614,7 @@ private struct AnalyzeButton: View { latitude: 37.7749, longitude: -122.4194, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1/Views/Tools/RxLogView.swift b/MC1/Views/Tools/RxLogView.swift index 78772ad2b..7cee64e83 100644 --- a/MC1/Views/Tools/RxLogView.swift +++ b/MC1/Views/Tools/RxLogView.swift @@ -449,9 +449,20 @@ struct RxLogRowView: View { DetailRow(label: L10n.Tools.Tools.RxLog.channelNameLabel, value: channelName) } if let transportCode = entry.transportCode, !transportCode.isEmpty { + let regionValue: String = switch RegionScopeSemantics.coalesce( + scope: entry.regionScope, + matches: entry.regionScopeMatches + ) { + case .none: + L10n.Tools.Tools.RxLog.regionUnresolved + case let .unique(name): + name + case let .ambiguous(names): + ListFormatter.localizedString(byJoining: names) + } DetailRow( label: L10n.Tools.Tools.RxLog.regionLabel, - value: entry.regionScope ?? L10n.Tools.Tools.RxLog.regionUnresolved + value: regionValue ) } if let text = entry.decodedText { diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift index e058ca633..db7b80e54 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift @@ -125,12 +125,11 @@ public extension ConnectionManager { """) // Reset stale room session connections from previous app launch - let resetStore = createStandalonePersistenceStore() - try? await resetStore.resetAllRemoteNodeSessionConnections() + try? await persistenceStore.resetAllRemoteNodeSessionConnections() // Populate radioID on existing devices and backfill deduplication keys (one-time migration) do { - try await resetStore.performRadioIDMigration() + try await persistenceStore.performRadioIDMigration() } catch { logger.error("radioID migration failed: \(error)") } @@ -138,7 +137,7 @@ public extension ConnectionManager { // 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() + try await persistenceStore.performChannelFloodScopeMigration() } catch { logger.error("channel flood-scope migration failed: \(error)") } @@ -146,7 +145,7 @@ public extension ConnectionManager { // Zero accumulated unread counts on repeater-type contacts and repeater-role // sessions so the badge stops including invisible records. do { - try await resetStore.performRepeaterUnreadCountMigration() + try await persistenceStore.performRepeaterUnreadCountMigration() } catch { logger.error("repeater unread-count migration failed: \(error)") } @@ -154,7 +153,7 @@ public extension ConnectionManager { // Backfill sortDate from createdAt on pre-existing messages so date-header // grouping keeps their current display order. do { - try await resetStore.performSortDateBackfillMigration() + try await persistenceStore.performSortDateBackfillMigration() } catch { logger.error("sortDate backfill migration failed: \(error)") } @@ -164,7 +163,7 @@ public extension ConnectionManager { // 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() + try await persistenceStore.performSortDateResetMigration() } catch { logger.error("sortDate reset migration failed: \(error)") } @@ -213,7 +212,7 @@ public extension ConnectionManager { connectionIntent = .wantsConnection() // Check if last device was WiFi - try WiFi first - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore if let device = try? await dataStore.fetchDevice(id: lastDeviceID), let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { if case let .wifi(host, port, _) = wifiMethod { @@ -678,7 +677,7 @@ public extension ConnectionManager { // Create services let newServices = ServiceContainer( session: session, - modelContainer: modelContainer, + dataStore: persistenceStore, radioID: MockDataProvider.simulatorDeviceID, appStateProvider: appStateProvider, connectionStateEvents: connectionStateEvents, diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift index 6f321748a..016e6a787 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift @@ -114,7 +114,7 @@ public extension ConnectionManager { if let connectedID = connectedDevice?.id { protectedIDs.insert(connectedID) } if let attemptID = activeConnectionAttemptDeviceID { protectedIDs.insert(attemptID) } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore for info in pairing.registeredDeviceInfos() where !protectedIDs.contains(info.id) { let existingDevice: DeviceDTO? @@ -152,7 +152,7 @@ public extension ConnectionManager { logger.warning("Failed to remove from pairing registry: \(error.localizedDescription)") } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore try? await dataStore.demoteDeviceToGhost(id: deviceID) // Always clear this device's bond verification; store keys are holder-matched. @@ -221,7 +221,7 @@ public extension ConnectionManager { await disconnect(reason: .forgetDevice) try await pairing.removeDevice(deviceID) - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { if deleteData { try await dataStore.deleteDeviceAndData(id: deviceID) @@ -250,7 +250,7 @@ public extension ConnectionManager { logger.warning("Failed to remove device from pairing registry: \(error.localizedDescription)") } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { try await dataStore.deleteDeviceAndData(id: id) } catch { @@ -272,7 +272,7 @@ public extension ConnectionManager { throw ConnectionError.notConnected } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore let allContacts = try await dataStore.fetchContacts(radioID: radioID) return allContacts.count(where: { !$0.isFavorite }) } @@ -284,14 +284,14 @@ public extension ConnectionManager { try await removeContacts(matching: { !$0.isFavorite }) } - /// Removes non-favorite contacts whose `lastModified` timestamp is older than the given threshold. + /// Removes non-favorite contacts whose recency 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 + return try await removeContacts(matching: { $0.matchesStaleNodePrune(cutoff: cutoff) }) { contact in + let ageDays = (Int(Date().timeIntervalSince1970) - Int(contact.recencyTimestamp)) / 86400 let keyPrefix = contact.publicKeyHex.prefix(8) self.logger.info("Auto-removed stale node '\(contact.name)' [\(keyPrefix)] (last heard \(ageDays)d ago)") } @@ -314,7 +314,7 @@ public extension ConnectionManager { throw ConnectionError.notConnected } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore let allContacts = try await dataStore.fetchContacts(radioID: radioID) // Never bulk-remove the ZephCore V-contact: CMD_REMOVE turns firmware v.contact off. let selfPublicKey = connectedDevice?.publicKey @@ -531,7 +531,7 @@ public extension ConnectionManager { /// 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 dataStore = persistenceStore let devices = try await dataStore.fetchDevices() logger.info("fetchSavedDevices returning \(devices.count) devices") return devices @@ -542,7 +542,7 @@ public extension ConnectionManager { /// - 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) + let dataStore = persistenceStore try await dataStore.demoteDeviceToGhost(id: id) // Always clear this device's bond verification; store keys are holder-matched @@ -595,7 +595,7 @@ extension ConnectionManager: DevicePairingDelegate { } // Demote to ghost — preserve publicKey ↔ radioID bridge - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { try await dataStore.demoteDeviceToGhost(id: bluetoothID) } catch { @@ -621,7 +621,7 @@ extension ConnectionManager: DevicePairingDelegate { } // Delete device record only — no data exists for a failed pairing - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { try await dataStore.deleteDevice(id: bluetoothID) logger.info("Deleted device record after failed pairing") diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift index e27bafbc7..caa4d36c5 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift @@ -287,7 +287,7 @@ extension ConnectionManager { if connectionState == .disconnected, connectionIntent.wantsConnection, let lastDeviceID = lastConnectedDeviceID { - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore if let device = try? await dataStore.fetchDevice(id: lastDeviceID), let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { if case let .wifi(host, port, _) = wifiMethod { @@ -312,6 +312,11 @@ extension ConnectionManager { /// - 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 { + let host = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { + throw WiFiTransportError.invalidHost + } + logger.info("Connecting via WiFi to \(host):\(port)") // Disconnect existing connection if any diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift index e2fb3da92..c1c372d99 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift @@ -205,6 +205,10 @@ public final class ConnectionManager { /// Installed by `AppState` during initialization. public var onDeviceSynced: (() async -> Void)? + /// Called when `clearPersistedConnection(for:)` clears the last-connected slot. + /// Installed by `AppState` during initialization. + public var onLastConnectedDeviceCleared: (@MainActor @Sendable () -> Void)? + /// Provider for app foreground/background state detection. /// Installed by `AppState` during initialization. public var appStateProvider: AppStateProvider? @@ -230,10 +234,9 @@ public final class ConnectionManager { pairing.hasSystemPairingRegistry } - /// Creates a standalone persistence store for operations that don't require services - public func createStandalonePersistenceStore() -> PersistenceStore { - PersistenceStore(modelContainer: modelContainer) - } + /// Process-lifetime persistence actor, created once in `init`. Session + /// services receive this instance; no production path mints a second one. + public let persistenceStore: PersistenceStore // MARK: - Internal Components @@ -438,6 +441,10 @@ public final class ConnectionManager { /// Test override for lastConnectedDeviceID var testLastConnectedDeviceID: UUID? + /// When true, `lastConnectedDeviceID` is nil so never-paired tests can + /// drive `offlineDataStore` without touching the host UserDefaults. + var testForceNeverPaired = false + /// When set, the first watchdog sleep uses this instead of 30s so natural-exit /// tests can complete without waiting on production backoff. var testWatchdogInitialDelay: Duration? @@ -466,6 +473,7 @@ public final class ConnectionManager { /// The last connected device ID (for auto-reconnect) public var lastConnectedDeviceID: UUID? { #if DEBUG + if testForceNeverPaired { return nil } if let testID = testLastConnectedDeviceID { return testID } @@ -509,11 +517,15 @@ public final class ConnectionManager { /// already observed a true `shouldPersistBondRefresh` still no-ops. func clearPersistedConnection(for deviceID: UUID) async { bondRefreshPersistEpoch &+= 1 + let wasLastConnected = lastConnectionStore.deviceID == deviceID await stateMachine.clearBondVerification(deviceID: deviceID) if await stateMachine.isAppSessionLive(deviceID: deviceID) { await stateMachine.setAppSessionLive(deviceID: nil) } lastConnectionStore.clear(for: deviceID) + if wasLastConnected { + onLastConnectedDeviceCleared?() + } } /// Persist path for RSSI bond refresh: snapshot epoch, re-validate on the SM, @@ -587,6 +599,7 @@ public final class ConnectionManager { pairing: (any DevicePairingService)? = nil ) { self.modelContainer = modelContainer + persistenceStore = PersistenceStore(modelContainer: modelContainer) self.defaults = defaults lastConnectionStore = LastConnectionStore(defaults: defaults) connectionIntent = .restored(from: defaults) @@ -851,10 +864,9 @@ public final class ConnectionManager { // 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 existingDevice = try? await persistenceStore.fetchDevice(id: deviceID) let deviceByPublicKey: DeviceDTO? = if existingDevice == nil { - try? await standaloneStore.fetchDevice(publicKey: selfInfo.publicKey) + try? await persistenceStore.fetchDevice(publicKey: selfInfo.publicKey) } else { nil } @@ -863,7 +875,7 @@ public final class ConnectionManager { let newServices = ServiceContainer( session: session, - modelContainer: modelContainer, + dataStore: persistenceStore, radioID: resolvedRadioID, appStateProvider: appStateProvider, connectionStateEvents: connectionStateEvents, diff --git a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift index 1d001be6b..7fdb35adf 100644 --- a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift +++ b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift @@ -9,7 +9,7 @@ extension WiFiTransportError: @retroactive LocalizedError { 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." + "Connection timed out. Check the hostname or IP address and ensure the device is reachable." case .notConnected: "Not connected to device." case let .sendFailed(reason): @@ -17,7 +17,7 @@ extension WiFiTransportError: @retroactive LocalizedError { case .sendTimeout: "Send operation timed out." case .invalidHost: - "Invalid IP address." + "Invalid hostname or IP address." case .invalidPort: "Invalid port number." case .notConfigured: diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index 27c20b381..7b6ecd2e2 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -32,8 +32,10 @@ public final class Contact { /// 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 + /// Encoded outbound path length (0xFF = flood; upper 2 bits = hash mode, + /// lower 6 bits = hop count). Stored as Int so leftover Int8 flood sentinels + /// (-1) survive SwiftData fetch; the DTO exposes UInt8 via truncatingIfNeeded. + public var outPathLength: Int /// Outgoing routing path (up to 64 bytes) public var outPath: Data @@ -50,6 +52,11 @@ public final class Contact { /// Last modification timestamp (for sync watermarking) public var lastModified: UInt32 + /// Phone-clock epoch seconds of the last mesh liveness evidence this phone + /// recorded for the contact (advert receive, inbound DM, successful ping, + /// path-discovery response). Monotonic; 0 means never heard by this phone. + public var lastHeardTimestamp: UInt32 = 0 + /// Local nickname override (optional) public var nickname: String? @@ -93,6 +100,7 @@ public final class Contact { latitude: Double = 0, longitude: Double = 0, lastModified: UInt32 = 0, + lastHeardTimestamp: UInt32, nickname: String? = nil, isBlocked: Bool = false, isMuted: Bool = false, @@ -110,12 +118,13 @@ public final class Contact { self.name = name self.typeRawValue = typeRawValue self.flags = flags - self.outPathLength = outPathLength + self.outPathLength = Int(outPathLength) self.outPath = outPath self.lastAdvertTimestamp = lastAdvertTimestamp self.latitude = latitude self.longitude = longitude self.lastModified = lastModified + self.lastHeardTimestamp = lastHeardTimestamp self.nickname = nickname self.isBlocked = isBlocked self.isMuted = isMuted @@ -144,6 +153,7 @@ public final class Contact { latitude: dto.latitude, longitude: dto.longitude, lastModified: dto.lastModified, + lastHeardTimestamp: dto.lastHeardTimestamp ?? 0, nickname: dto.nickname, isBlocked: dto.isBlocked, isMuted: dto.isMuted, @@ -166,12 +176,13 @@ public final class Contact { name = dto.name typeRawValue = dto.typeRawValue flags = dto.flags - outPathLength = dto.outPathLength + outPathLength = Int(dto.outPathLength) outPath = dto.outPath lastAdvertTimestamp = dto.lastAdvertTimestamp latitude = dto.latitude longitude = dto.longitude lastModified = dto.lastModified + lastHeardTimestamp = max(lastHeardTimestamp, dto.lastHeardTimestamp ?? 0) nickname = dto.nickname isBlocked = dto.isBlocked isMuted = dto.isMuted @@ -198,6 +209,7 @@ public final class Contact { latitude: frame.latitude, longitude: frame.longitude, lastModified: frame.lastModified, + lastHeardTimestamp: 0, isFavorite: (frame.flags & 0x01) != 0 ) } @@ -223,7 +235,7 @@ public extension Contact { /// Whether this contact uses flood routing var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel + UInt8(truncatingIfNeeded: outPathLength) == PacketBuilder.floodPathSentinel } /// Whether this contact has a known, valid location @@ -251,7 +263,7 @@ public extension Contact { 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 + outPathLength = Int(frame.outPathLength) outPath = frame.outPath lastAdvertTimestamp = frame.lastAdvertTimestamp latitude = frame.latitude @@ -266,7 +278,7 @@ public extension Contact { type: type, typeRawValue: typeRawValue, flags: flags, - outPathLength: outPathLength, + outPathLength: UInt8(truncatingIfNeeded: outPathLength), outPath: outPath, name: name, lastAdvertTimestamp: lastAdvertTimestamp, @@ -293,6 +305,8 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, public let latitude: Double public let longitude: Double public let lastModified: UInt32 + /// Phone-clock epoch seconds; nil in legacy backup envelopes means never heard. + public let lastHeardTimestamp: UInt32? public let nickname: String? public let isBlocked: Bool public let isMuted: Bool @@ -311,12 +325,13 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, name = contact.name typeRawValue = contact.typeRawValue flags = contact.flags - outPathLength = contact.outPathLength + outPathLength = UInt8(truncatingIfNeeded: contact.outPathLength) outPath = contact.outPath lastAdvertTimestamp = contact.lastAdvertTimestamp latitude = contact.latitude longitude = contact.longitude lastModified = contact.lastModified + lastHeardTimestamp = contact.lastHeardTimestamp nickname = contact.nickname isBlocked = contact.isBlocked isMuted = contact.isMuted @@ -343,6 +358,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, latitude: Double, longitude: Double, lastModified: UInt32, + lastHeardTimestamp: UInt32?, nickname: String?, isBlocked: Bool, isMuted: Bool, @@ -366,6 +382,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, self.latitude = latitude self.longitude = longitude self.lastModified = lastModified + self.lastHeardTimestamp = lastHeardTimestamp self.nickname = nickname self.isBlocked = isBlocked self.isMuted = isMuted @@ -451,6 +468,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -466,6 +484,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -481,6 +500,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -511,8 +531,20 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, // MARK: - RepeaterResolvable + /// Max of radio `lastModified` and phone-clock `lastHeardTimestamp`. + /// Legacy rows with a nil/0 heard stamp keep `lastModified` behavior. + public var recencyTimestamp: UInt32 { + max(lastModified, lastHeardTimestamp ?? 0) + } + + /// Match used by `ConnectionManager.removeStaleNodes` for the given cutoff + /// (epoch seconds). Favorites never match. + public func matchesStaleNodePrune(cutoff: UInt32) -> Bool { + !isFavorite && recencyTimestamp < cutoff + } + public var recencyDate: Date { - Date(timeIntervalSince1970: Double(lastModified)) + Date(timeIntervalSince1970: Double(recencyTimestamp)) } public var resolvableName: String { diff --git a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift index def496802..693361288 100644 --- a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift +++ b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift @@ -39,8 +39,10 @@ final class DiscoveredNode { /// Node longitude var longitude: Double - /// Encoded routing path length (0xFF = flood) - var outPathLength: UInt8 + /// Encoded routing path length (0xFF = flood). Stored as Int so leftover Int8 + /// flood sentinels (-1) survive SwiftData fetch; the DTO exposes UInt8 via + /// truncatingIfNeeded. + var outPathLength: Int /// Routing path data (up to 64 bytes) var outPath: Data @@ -79,7 +81,7 @@ final class DiscoveredNode { self.lastAdvertTimestamp = lastAdvertTimestamp self.latitude = latitude self.longitude = longitude - self.outPathLength = outPathLength + self.outPathLength = Int(outPathLength) self.outPath = outPath self.inboundHopCount = inboundHopCount self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp @@ -188,7 +190,7 @@ public struct DiscoveredNodeDTO: Sendable, Equatable, Identifiable, Codable, Rep lastAdvertTimestamp = node.lastAdvertTimestamp latitude = node.latitude longitude = node.longitude - outPathLength = node.outPathLength + outPathLength = UInt8(truncatingIfNeeded: node.outPathLength) outPath = node.outPath inboundHopCount = node.inboundHopCount inboundHopAdvertTimestamp = node.inboundHopAdvertTimestamp diff --git a/MC1Services/Sources/MC1Services/Models/FailedSendConversationKeys.swift b/MC1Services/Sources/MC1Services/Models/FailedSendConversationKeys.swift new file mode 100644 index 000000000..767d5e51f --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/FailedSendConversationKeys.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Conversation identities with at least one unseen outgoing `.failed` send. +/// Channel IDs are resolved under the query `radioID` so list mapping cannot attach another radio's slot. +public struct FailedSendConversationKeys: Sendable, Equatable { + public let contactIDs: Set + public let channelIDs: Set + public let roomSessionIDs: Set + + public init( + contactIDs: Set = [], + channelIDs: Set = [], + roomSessionIDs: Set = [] + ) { + self.contactIDs = contactIDs + self.channelIDs = channelIDs + self.roomSessionIDs = roomSessionIDs + } + + public static let empty = FailedSendConversationKeys() +} diff --git a/MC1Services/Sources/MC1Services/Models/Message.swift b/MC1Services/Sources/MC1Services/Models/Message.swift index 135a85acc..b4f1ca73b 100644 --- a/MC1Services/Sources/MC1Services/Models/Message.swift +++ b/MC1Services/Sources/MC1Services/Models/Message.swift @@ -134,6 +134,10 @@ public final class Message { /// Whether the user has scrolled to see this mention (for tracking unread mentions) public var mentionSeen: Bool = false + /// Whether the user has opened the conversation after this outgoing send failed. + /// The conversation-list badge shows only unseen `.failed` rows. + public var failureSeen: Bool = false + /// Whether the timestamp was corrected due to sender clock being invalid public var timestampCorrected: Bool = false @@ -149,12 +153,15 @@ public final class Message { /// 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`). + /// Confident single flood-region name from RxLog correlation, or nil when + /// unresolved or ambiguous. Read through `RegionScopeSemantics.coalesce` + /// with `regionScopeMatches` — nil alone is not "Unknown." public var regionScope: String? + /// Sorted known public regions that verify this packet's transport code. + /// Empty, one name, or two-plus for multi-match. Defaults to `[]`. + public var regionScopeMatches: [String] = [] + /// Heard repeats for this message (cascade delete) @Relationship(deleteRule: .cascade, inverse: \MessageRepeat.message) var repeats: [MessageRepeat]? @@ -192,11 +199,13 @@ public final class Message { linkPreviewFetched: Bool = false, containsSelfMention: Bool = false, mentionSeen: Bool = false, + failureSeen: Bool = false, timestampCorrected: Bool = false, senderTimestamp: UInt32? = nil, reactionSummary: String? = nil, routeTypeRawValue: Int = -1, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) { self.id = id self.radioID = radioID @@ -230,11 +239,13 @@ public final class Message { self.linkPreviewFetched = linkPreviewFetched self.containsSelfMention = containsSelfMention self.mentionSeen = mentionSeen + self.failureSeen = failureSeen self.timestampCorrected = timestampCorrected self.senderTimestamp = senderTimestamp self.reactionSummary = reactionSummary self.routeTypeRawValue = routeTypeRawValue self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches } /// Builds a model instance directly from a DTO. Shared by backup batch-insert @@ -275,11 +286,13 @@ public final class Message { linkPreviewFetched: false, containsSelfMention: dto.containsSelfMention, mentionSeen: dto.mentionSeen, + failureSeen: dto.failureSeen, timestampCorrected: dto.timestampCorrected, senderTimestamp: dto.senderTimestamp, reactionSummary: dto.reactionSummary, routeTypeRawValue: dto.routeType.map { Int($0.rawValue) } ?? -1, - regionScope: dto.regionScope + regionScope: dto.regionScope, + regionScopeMatches: dto.regionScopeMatches ) } } @@ -360,11 +373,14 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { public var linkPreviewFetched: Bool public var containsSelfMention: Bool public var mentionSeen: Bool + public var failureSeen: Bool public var timestampCorrected: Bool public var senderTimestamp: UInt32? public var reactionSummary: String? public var routeType: RouteType? public var regionScope: String? + /// Sorted multi-match set. Empty when the backup key is missing. + public var regionScopeMatches: [String] /// Explicit Codable so backups predating ``sortDate`` decode cleanly. /// Legacy envelopes have no `sortDate` key; it falls back to `createdAt`, @@ -377,7 +393,8 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { roundTripTime, heardRepeats, sendCount, retryAttempt, maxRetryAttempts, deduplicationKey, linkPreviewURL, linkPreviewTitle, linkPreviewImageData, linkPreviewIconData, linkPreviewFetched, containsSelfMention, mentionSeen, - timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope + failureSeen, timestampCorrected, senderTimestamp, reactionSummary, routeType, + regionScope, regionScopeMatches } public init(from decoder: Decoder) throws { @@ -415,11 +432,14 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { linkPreviewFetched = try container.decode(Bool.self, forKey: .linkPreviewFetched) containsSelfMention = try container.decode(Bool.self, forKey: .containsSelfMention) mentionSeen = try container.decode(Bool.self, forKey: .mentionSeen) + failureSeen = try container.decodeIfPresent(Bool.self, forKey: .failureSeen) ?? false 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) + // Missing key → []; do not invent matches from regionScope. + regionScopeMatches = try container.decodeIfPresent([String].self, forKey: .regionScopeMatches) ?? [] } public init(from message: Message, includeLinkPreviewBlobs: Bool = true) { @@ -464,12 +484,14 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { } containsSelfMention = message.containsSelfMention mentionSeen = message.mentionSeen + failureSeen = message.failureSeen timestampCorrected = message.timestampCorrected senderTimestamp = message.senderTimestamp reactionSummary = message.reactionSummary routeType = UInt8(exactly: message.routeTypeRawValue) .flatMap(RouteType.init(rawValue:)) regionScope = message.regionScope + regionScopeMatches = message.regionScopeMatches } /// Memberwise initializer for creating DTOs directly @@ -506,11 +528,13 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { linkPreviewFetched: Bool = false, containsSelfMention: Bool = false, mentionSeen: Bool = false, + failureSeen: Bool = false, timestampCorrected: Bool = false, senderTimestamp: UInt32? = nil, reactionSummary: String? = nil, routeType: RouteType? = nil, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) { self.id = id self.radioID = radioID @@ -544,11 +568,13 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { self.linkPreviewFetched = linkPreviewFetched self.containsSelfMention = containsSelfMention self.mentionSeen = mentionSeen + self.failureSeen = failureSeen self.timestampCorrected = timestampCorrected self.senderTimestamp = senderTimestamp self.reactionSummary = reactionSummary self.routeType = routeType self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches } public var isOutgoing: Bool { diff --git a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift index 00671ccb3..09889d899 100644 --- a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift @@ -15,6 +15,8 @@ public struct MessageEnvelope: Sendable, Hashable { public let hasFailed: Bool public let containsSelfMention: Bool public let mentionSeen: Bool + /// Present only on channel incoming cluster-end rows. Never a JPEG. + public let incomingAvatar: IncomingAvatarIdentity? public init( messageID: UUID, @@ -25,7 +27,8 @@ public struct MessageEnvelope: Sendable, Hashable { date: Date, hasFailed: Bool, containsSelfMention: Bool, - mentionSeen: Bool + mentionSeen: Bool, + incomingAvatar: IncomingAvatarIdentity? ) { self.messageID = messageID self.isOutgoing = isOutgoing @@ -36,6 +39,7 @@ public struct MessageEnvelope: Sendable, Hashable { self.hasFailed = hasFailed self.containsSelfMention = containsSelfMention self.mentionSeen = mentionSeen + self.incomingAvatar = incomingAvatar } /// Returns a new envelope with `status` (and the derived `hasFailed`) @@ -50,7 +54,23 @@ public struct MessageEnvelope: Sendable, Hashable { date: date, hasFailed: status == .failed, containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen + mentionSeen: mentionSeen, + incomingAvatar: incomingAvatar + ) + } + + public func with(incomingAvatar: IncomingAvatarIdentity?) -> MessageEnvelope { + MessageEnvelope( + messageID: messageID, + isOutgoing: isOutgoing, + senderName: senderName, + senderResolution: senderResolution, + status: status, + date: date, + hasFailed: hasFailed, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen, + incomingAvatar: incomingAvatar ) } } diff --git a/MC1Services/Sources/MC1Services/Models/RegionScopeSemantics.swift b/MC1Services/Sources/MC1Services/Models/RegionScopeSemantics.swift new file mode 100644 index 000000000..f5655e707 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/RegionScopeSemantics.swift @@ -0,0 +1,98 @@ +import Foundation +import MeshCore + +/// Dual-field read/write helpers for message and RX-log region labeling. +/// +/// MeshCore owns `RegionMatchResult`. This type maps match results to storage +/// fields and coalesces dual fields for readers (including rows that only have +/// `regionScope`). +public enum RegionScopeSemantics { + /// Compact chip join for multi-match labels (e.g. `de-hh / de-by`). + public static let chipNameSeparator = " / " + + /// Map a match result into the two persisted fields. + /// + /// - none → `(nil, [])` + /// - unique → `(name, [name])` + /// - ambiguous → `(nil, sorted names)` — never a single first-match name + public static func storageFields( + from match: RegionMatchResult + ) -> (regionScope: String?, regionScopeMatches: [String]) { + switch match { + case .none: + return (nil, []) + case let .unique(name): + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return (nil, []) } + return (trimmed, [trimmed]) + case let .ambiguous(names): + let filtered = Self.filteredSortedNames(names) + switch filtered.count { + case 0: + return (nil, []) + case 1: + return (filtered[0], filtered) + default: + return (nil, filtered) + } + } + } + + /// Dual-field read. Multi-match wins over a non-nil `regionScope` so a stale + /// single name never surfaces when the match list has two or more entries. + /// + /// Priority: matches ≥ 2 → ambiguous; matches == 1 → unique; empty matches + /// with non-nil scope → unique (legacy); both empty → none. + public static func coalesce( + scope: String?, + matches: [String] + ) -> RegionMatchResult { + let filtered = Self.filteredSortedNames(matches) + switch filtered.count { + case 0: + if let scope { + let trimmed = scope.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? .none : .unique(trimmed) + } + return .none + case 1: + return .unique(filtered[0]) + default: + return .ambiguous(filtered) + } + } + + /// Chip label from a coalesced result. Nil hides the chip. + public static func chipLabel(from match: RegionMatchResult) -> String? { + switch match { + case .none: + nil + case let .unique(name): + name + case let .ambiguous(names): + names.joined(separator: chipNameSeparator) + } + } + + /// Post-filter match names for popover / a11y / Message Info lists. + static func matchNames(from match: RegionMatchResult) -> [String] { + switch match { + case .none: + [] + case let .unique(name): + [name] + case let .ambiguous(names): + names + } + } + + /// Blank-filter then `localizedStandardCompare` ascending, de-duplicated. + private static func filteredSortedNames(_ names: [String]) -> [String] { + let trimmed = names + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return Array(Set(trimmed)).sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + } +} diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/DetectedLanguage.swift b/MC1Services/Sources/MC1Services/Models/Rendering/DetectedLanguage.swift new file mode 100644 index 000000000..a7b874956 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/Rendering/DetectedLanguage.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Result of running language detection for one message. +/// Missing dictionary key means detection has not run yet. +public enum DetectedLanguage: Sendable, Equatable { + case undetermined + case identified(languageCode: String) + + public var code: String? { + switch self { + case .undetermined: nil + case let .identified(languageCode): languageCode + } + } +} diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift index ed5f816e2..99414fd96 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift @@ -33,6 +33,9 @@ public struct EnvInputs: Sendable, Hashable { /// `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 + /// App-locale language code (`"en"`, `"de"`, `"zh"`), never a region qualifier. + /// A change forces a full `buildItems()` so Translation chrome re-evaluates. + public let preferredLanguageCode: 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 @@ -55,7 +58,8 @@ public struct EnvInputs: Sendable, Hashable { isOffline: Bool, currentUserName: String, themeID: String, - contentSizeCategory: String + contentSizeCategory: String, + preferredLanguageCode: String ) { self.autoPlayGIFs = autoPlayGIFs self.showIncomingPath = showIncomingPath @@ -70,6 +74,7 @@ public struct EnvInputs: Sendable, Hashable { self.currentUserName = currentUserName self.themeID = themeID self.contentSizeCategory = contentSizeCategory + self.preferredLanguageCode = preferredLanguageCode } /// Identifier of the built-in default theme. Shared so `EnvInputs.default` and `Theme.default.id` @@ -81,6 +86,14 @@ public struct EnvInputs: Sendable, Hashable { /// cannot drift apart. public static let defaultContentSizeCategory = "large" + /// Fallback language code when `Locale.Language.languageCode` is missing. + public static let defaultPreferredLanguageCode = "en" + + /// App-locale language subtag used as the Translation target (`en-US` → `en`). + public static func preferredLanguageCode(from locale: Locale) -> String { + locale.language.languageCode?.identifier ?? defaultPreferredLanguageCode + } + public static let `default` = EnvInputs( autoPlayGIFs: AppStorageKey.defaultAutoPlayGIFs, showIncomingPath: AppStorageKey.defaultShowIncomingPath, @@ -94,6 +107,7 @@ public struct EnvInputs: Sendable, Hashable { isOffline: false, currentUserName: "", themeID: defaultThemeID, - contentSizeCategory: defaultContentSizeCategory + contentSizeCategory: defaultContentSizeCategory, + preferredLanguageCode: defaultPreferredLanguageCode ) } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/IncomingAvatarIdentity.swift b/MC1Services/Sources/MC1Services/Models/Rendering/IncomingAvatarIdentity.swift new file mode 100644 index 000000000..48132548a --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/Rendering/IncomingAvatarIdentity.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct IncomingAvatarIdentity: Sendable, Hashable { + public let name: String + /// Unique-name channel match. Local `Contact.id`, never a DM conversation key. + public let matchedContactID: UUID? + public let imageRevision: UInt64? + + public init(name: String, matchedContactID: UUID?, imageRevision: UInt64?) { + self.name = name + self.matchedContactID = matchedContactID + self.imageRevision = imageRevision + } + + public static func initials(name: String) -> IncomingAvatarIdentity { + IncomingAvatarIdentity(name: name, matchedContactID: nil, imageRevision: nil) + } + + /// Unique-name photo only when `senderNodeName` is non-empty after trim. + /// Prefix-resolved `displayName` is initials chrome, never a table key. + public static func resolve( + senderNodeName: String?, + displayName: String, + table: [String: IncomingAvatarIdentity] + ) -> IncomingAvatarIdentity { + if let raw = senderNodeName?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let identity = table[raw.lowercased()] { + return identity + } + return .initials(name: displayName) + } + + /// Process-lifetime token. `Hasher` mixes the JPEG bytes; do not persist. + public static func revision(of data: Data?) -> UInt64? { + guard let data, !data.isEmpty else { return nil } + var hasher = Hasher() + hasher.combine(data) + return UInt64(bitPattern: Int64(hasher.finalize())) + } +} diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift index 47bae5243..7753d189d 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift @@ -49,6 +49,11 @@ public struct MessageBuildInputs: Sendable, Hashable { public let showNewMessagesDivider: Bool /// True for the first message of a new calendar day; drives the day separator. public let showDayDivider: Bool + /// Present only on channel incoming cluster-end rows. Never a JPEG. + public let incomingAvatar: IncomingAvatarIdentity? + /// Already-decided Translation chrome. The builder copies this onto the + /// text payload and never calls the detector. + public let translation: MessageTranslationChrome? public init( messageID: UUID, @@ -73,7 +78,9 @@ public struct MessageBuildInputs: Sendable, Hashable { showDirectionGap: Bool, showSenderName: Bool, showNewMessagesDivider: Bool, - showDayDivider: Bool = false + showDayDivider: Bool = false, + incomingAvatar: IncomingAvatarIdentity? = nil, + translation: MessageTranslationChrome? = nil ) { self.messageID = messageID self.previewState = previewState @@ -98,5 +105,7 @@ public struct MessageBuildInputs: Sendable, Hashable { self.showSenderName = showSenderName self.showNewMessagesDivider = showNewMessagesDivider self.showDayDivider = showDayDivider + self.incomingAvatar = incomingAvatar + self.translation = translation } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift index 0cb47c371..24e0a053f 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift @@ -14,7 +14,11 @@ public struct MessageFooter: Sendable, Hashable { public let showHop: Bool public let hopCount: Int public let formattedPath: String? + /// Compact chip text; nil hides the region chip. Multi-match is the + /// slash-joined label baked at footer build time. public let regionToShow: String? + /// Candidate region names for popover and accessibility. Count > 1 is ambiguous. + public let regionMatchNames: [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 @@ -38,11 +42,17 @@ public struct MessageFooter: Sendable, Hashable { public let maxRetryAttempts: Int public let sendCount: Int + /// True when `regionMatchNames` has more than one entry. Derived, not stored. + public var regionIsAmbiguous: Bool { + regionMatchNames.count > 1 + } + public init( showHop: Bool, hopCount: Int, formattedPath: String?, regionToShow: String?, + regionMatchNames: [String] = [], sendTimeToShow: Date?, sendTimeWasCorrected: Bool, showStatusRow: Bool, @@ -57,6 +67,7 @@ public struct MessageFooter: Sendable, Hashable { self.hopCount = hopCount self.formattedPath = formattedPath self.regionToShow = regionToShow + self.regionMatchNames = regionMatchNames self.sendTimeToShow = sendTimeToShow self.sendTimeWasCorrected = sendTimeWasCorrected self.showStatusRow = showStatusRow @@ -68,7 +79,7 @@ public struct MessageFooter: Sendable, Hashable { self.sendCount = sendCount } - /// Returns a new footer with `status` overridden. Eliminates the 10-field + /// Returns a new footer with `status` overridden. Eliminates the multi-field /// rebuild at status-flip sites. public func with(status: MessageStatus) -> MessageFooter { MessageFooter( @@ -76,6 +87,7 @@ public struct MessageFooter: Sendable, Hashable { hopCount: hopCount, formattedPath: formattedPath, regionToShow: regionToShow, + regionMatchNames: regionMatchNames, sendTimeToShow: sendTimeToShow, sendTimeWasCorrected: sendTimeWasCorrected, showStatusRow: showStatusRow, diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift index da261f3c8..89ab05d4b 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift @@ -50,18 +50,28 @@ public struct MessageItem: Identifiable, Sendable, Hashable { 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 var translation: MessageTranslationChrome? { + for fragment in content { + if case let .text(payload) = fragment { + return payload.translation + } + } + return nil + } + + /// Returns a new item with the supplied envelope, footer, and/or grouping + /// overridden. Eliminates the 6-field rebuild at single-row mutation sites. public func with( envelope: MessageEnvelope? = nil, - footer: MessageFooter? = nil + footer: MessageFooter? = nil, + grouping: GroupingFlags? = nil ) -> MessageItem { MessageItem( id: id, envelope: envelope ?? self.envelope, content: content, footer: footer ?? self.footer, - grouping: grouping, + grouping: grouping ?? self.grouping, shouldRequestPreviewFetch: shouldRequestPreviewFetch ) } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift index b938b8f7d..c721c0823 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift @@ -6,18 +6,23 @@ public struct MessageTextPayload: Sendable, Hashable { public let baseColor: BaseColorSlot public let isOutgoing: Bool public let currentUserName: String + /// Nil means no Translation offer row. `raw` / `formatted` always describe + /// the stored original; the view chooses the visible body from `phase`. + public let translation: MessageTranslationChrome? public init( raw: String, formatted: AttributedString?, baseColor: BaseColorSlot, isOutgoing: Bool, - currentUserName: String + currentUserName: String, + translation: MessageTranslationChrome? = nil ) { self.raw = raw self.formatted = formatted self.baseColor = baseColor self.isOutgoing = isOutgoing self.currentUserName = currentUserName + self.translation = translation } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTranslationChrome.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTranslationChrome.swift new file mode 100644 index 000000000..fda5ac96d --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTranslationChrome.swift @@ -0,0 +1,45 @@ +import Foundation + +/// In-bubble Translation offer chrome. `nil` on the payload means no row; +/// stored message text is never replaced. +public struct MessageTranslationChrome: Sendable, Hashable { + public enum Phase: Sendable, Hashable { + case offer + case inProgress + case showing(translatedText: String, targetLanguageCode: String) + } + + public let phase: Phase + public let sourceLanguageCode: String + + public init(phase: Phase, sourceLanguageCode: String) { + self.phase = phase + self.sourceLanguageCode = sourceLanguageCode + } + + /// In-flight wins; showing wins only if its target still matches preferred. + /// Otherwise an offer exists only when detection differs from preferred. + public static func resolved( + detected: DetectedLanguage?, + phase: Phase?, + preferredLanguageCode: String + ) -> MessageTranslationChrome? { + guard case let .identified(languageCode: sourceLanguageCode) = detected else { return nil } + if let phase { + switch phase { + case .inProgress: + return MessageTranslationChrome(phase: phase, sourceLanguageCode: sourceLanguageCode) + case let .showing(_, targetLanguageCode): + if MessageLanguageDetector.isSameLanguage(targetLanguageCode, preferredLanguageCode) { + return MessageTranslationChrome(phase: phase, sourceLanguageCode: sourceLanguageCode) + } + case .offer: + break + } + } + if MessageLanguageDetector.isSameLanguage(sourceLanguageCode, preferredLanguageCode) { + return nil + } + return MessageTranslationChrome(phase: .offer, sourceLanguageCode: sourceLanguageCode) + } +} diff --git a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift index a385a11a4..4b4eb7bd3 100644 --- a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift +++ b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift @@ -54,6 +54,10 @@ public final class RoomMessage { /// Maximum retry attempts configured public var maxRetryAttempts: Int = 0 + /// Whether the user has opened the room after this outgoing send failed. + /// The conversation-list badge shows only unseen `.failed` rows. + public var failureSeen: Bool = false + public init( id: UUID = UUID(), sessionID: UUID, @@ -99,6 +103,7 @@ public final class RoomMessage { roundTripTime = dto.roundTripTime retryAttempt = dto.retryAttempt maxRetryAttempts = dto.maxRetryAttempts + failureSeen = dto.failureSeen } } @@ -156,6 +161,7 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab public let roundTripTime: UInt32? public let retryAttempt: Int public let maxRetryAttempts: Int + public let failureSeen: Bool public init(from model: RoomMessage) { id = model.id @@ -172,6 +178,7 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab roundTripTime = model.roundTripTime retryAttempt = model.retryAttempt maxRetryAttempts = model.maxRetryAttempts + failureSeen = model.failureSeen } public init( @@ -187,7 +194,8 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab ackCode: UInt32? = nil, roundTripTime: UInt32? = nil, retryAttempt: Int = 0, - maxRetryAttempts: Int = 0 + maxRetryAttempts: Int = 0, + failureSeen: Bool = false ) { self.id = id self.sessionID = sessionID @@ -202,6 +210,7 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab self.roundTripTime = roundTripTime self.retryAttempt = retryAttempt self.maxRetryAttempts = maxRetryAttempts + self.failureSeen = failureSeen deduplicationKey = RoomMessage.generateDeduplicationKey( timestamp: timestamp, authorKeyPrefix: authorKeyPrefix, @@ -209,6 +218,31 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab ) } + private enum CodingKeys: String, CodingKey { + case id, sessionID, authorKeyPrefix, authorName, text, timestamp, createdAt, + isFromSelf, deduplicationKey, statusRawValue, ackCode, roundTripTime, + retryAttempt, maxRetryAttempts, failureSeen + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + sessionID = try container.decode(UUID.self, forKey: .sessionID) + authorKeyPrefix = try container.decode(Data.self, forKey: .authorKeyPrefix) + authorName = try container.decodeIfPresent(String.self, forKey: .authorName) + text = try container.decode(String.self, forKey: .text) + timestamp = try container.decode(UInt32.self, forKey: .timestamp) + createdAt = try container.decode(Date.self, forKey: .createdAt) + isFromSelf = try container.decode(Bool.self, forKey: .isFromSelf) + deduplicationKey = try container.decode(String.self, forKey: .deduplicationKey) + statusRawValue = try container.decode(Int.self, forKey: .statusRawValue) + ackCode = try container.decodeIfPresent(UInt32.self, forKey: .ackCode) + roundTripTime = try container.decodeIfPresent(UInt32.self, forKey: .roundTripTime) + retryAttempt = try container.decode(Int.self, forKey: .retryAttempt) + maxRetryAttempts = try container.decode(Int.self, forKey: .maxRetryAttempts) + failureSeen = try container.decodeIfPresent(Bool.self, forKey: .failureSeen) ?? false + } + public var authorDisplayName: String { authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() } diff --git a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift index f65e22267..9477b3b17 100644 --- a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift +++ b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift @@ -46,11 +46,13 @@ final class RxLogEntry { /// 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. + /// Confident single flood-region name, or nil when unresolved or ambiguous. + /// Local-only. Read via `RegionScopeSemantics.coalesce` with matches. var regionScope: String? + /// Sorted multi-match set for this packet. Local-only. + var regionScopeMatches: [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`. @@ -82,6 +84,7 @@ final class RxLogEntry { toContactName: String? = nil, senderTimestamp: Int? = nil, regionScope: String? = nil, + regionScopeMatches: [String] = [], payloadTypeBits: Int = 0 ) { self.id = id @@ -105,6 +108,7 @@ final class RxLogEntry { self.toContactName = toContactName self.senderTimestamp = senderTimestamp self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches self.payloadTypeBits = payloadTypeBits } } @@ -140,11 +144,15 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { /// Mutable to allow updating during re-decryption of older entries. public var senderTimestamp: UInt32? - /// Resolved flood region the sender transmitted under. Local-only. + /// Confident single flood-region name, or nil when unresolved or ambiguous. + /// Local-only. Read via `RegionScopeSemantics.coalesce` with matches. public let regionScope: String? + /// Sorted multi-match set. Local-only. + public let regionScopeMatches: [String] + /// Raw 4-bit payload-type nibble from the wire header (matches firmware - /// HMAC input). Persisted alongside `regionScope` for back-fill replay. + /// HMAC input). Persisted alongside region fields for back-fill replay. public let payloadTypeBits: UInt8 /// Transient - set by UI layer after decryption @@ -175,6 +183,7 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { toContactName = model.toContactName senderTimestamp = model.senderTimestamp.flatMap { UInt32(exactly: $0) } regionScope = model.regionScope + regionScopeMatches = model.regionScopeMatches payloadTypeBits = UInt8(model.payloadTypeBits & 0x0F) decodedText = model.decodedText } @@ -192,6 +201,7 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { toContactName: String? = nil, senderTimestamp: UInt32? = nil, regionScope: String? = nil, + regionScopeMatches: [String] = [], decodedText: String? = nil ) { self.id = id @@ -215,6 +225,7 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { self.toContactName = toContactName self.senderTimestamp = senderTimestamp self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches payloadTypeBits = parsed.payloadTypeBits self.decodedText = decodedText } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift index 1e2ca24e1..f4297806a 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift @@ -53,12 +53,36 @@ public protocol ContactPersisting: Actor { func deleteContact(id: UUID) async throws /// Deletes the contact only when no messages reference it. Insert-only - /// rollback of cancelled advert fetches: prefer an orphan contact over - /// cascade-wiping a concurrent DM. Implementations that share a ModelActor - /// with message storage must keep probe and delete in one isolation region - /// with no suspension between them. + /// rollback of a mid-sync radio delete: prefer an orphan contact over a + /// cascade that wipes a concurrent DM. Implementations that share a + /// ModelActor with message storage must keep probe and delete in one + /// isolation region with no suspension between them. + /// + /// PendingSend and Reaction cascade with their Message rows on + /// `deleteContact`, so a Message probe is sufficient. func deleteContactIfUnreferenced(id: UUID) async throws + /// Links direct messages stored before their contact row existed. Channel + /// rows are excluded by their non-nil channelIndex; the sender-key prefix + /// match runs in memory because `#Predicate` cannot express `Data.prefix`. + /// A prefix matching two contacts is left orphaned rather than guessed. + /// + /// Once a DM is adopted, `deleteContactIfUnreferenced` cannot roll that + /// contact back, and an incremental sync never prunes it. Ghost contacts + /// become permanent. That is the intended trade. + /// + /// Returns the number of messages adopted per contact id. Does not update + /// the springboard badge; the contact unread count is updated in place. + func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] + + /// Stamps phone-clock recency for a contact heard on air and the matching + /// DiscoveredNode lastHeard, creating that Discover row from stored radio + /// fields when it is missing. Returns true when a Contact row existed. + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool + /// Update contact's last message info (nil clears the date, removing from conversations list) func updateContactLastMessage(contactID: UUID, date: Date?) async throws @@ -135,4 +159,14 @@ public extension ContactPersisting where Self: MessagePersisting { guard messages.isEmpty else { return } try await deleteContact(id: id) } + + /// Default no-op for lightweight stubs. Concrete stores override; the method + /// must be `async throws` on the store so overload resolution does not pick + /// this empty default over the real implementation. + func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] { + [:] + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift index a1d7e1f2e..7c15a0772 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift @@ -21,9 +21,31 @@ public protocol MessagePersisting: Actor { /// Fetch messages for a contact func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] + /// Newest unread incoming message for a contact, or nil when none. + /// Used to post a direct-message banner after orphan DMs are adopted to a + /// contact that did not exist when they arrived. + func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? + /// Fetch messages for a channel func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] + /// Fetch the newest window for a contact: at least `floorLimit` rows, + /// widened to every row with `sortDate` at or newer than `anchorSortDate` + /// (nil means the floor alone). `hasMore` is whether older rows remain. + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) + + /// Fetch the newest window for a channel; see the contact variant. + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) + /// 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]] @@ -160,4 +182,11 @@ extension MessagePersisting { func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus) async throws { try await updateMessageAck(id: id, ackCode: ackCode, status: status, roundTripTime: nil) } + + /// Default no-op for lightweight stubs. Concrete stores override; the method + /// must be `async throws` on the store so overload resolution does not pick + /// this empty default over the real implementation. + func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? { + nil + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift index 94dbc3b36..8c1c50be4 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift @@ -1,5 +1,18 @@ import Foundation +/// Retention bounds for the RX log radio partition. +/// +/// Shared by prune defaults and region reprocess so the fetch window stays +/// aligned with how many rows can exist before pruning. +public enum RxLogRetention { + /// Rows kept after a prune pass. + public static let keepCount = 1000 + + /// Extra rows allowed before prune runs. Peak retained count is + /// `keepCount + pruneThreshold`. + public static let pruneThreshold = 100 +} + /// Store operations for RX log entries: persistence, lookup, and batch enrichment. public protocol RxLogPersisting: Actor { // MARK: - RxLogEntry Lookup @@ -36,15 +49,22 @@ public protocol RxLogPersisting: Actor { /// 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 transport-coded RX log entries for region reprocess. + /// + /// Bound by prune retention (`limit`, typically + /// `RxLogRetention.keepCount + RxLogRetention.pruneThreshold`). Scans the + /// radio partition (indexed `radioID` + `receivedAt`); `transportCode` itself + /// is unindexed. Includes rows that already have a region label so unique, + /// multi-match, and clear rewrites all work. + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) 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 dual region fields on RX log entries by id + func batchUpdateRxLogRegion( + updates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] + ) async throws /// Batch update RX log entries after successful decryption. /// Note: decodedText is transient and not persisted. @@ -52,19 +72,23 @@ public protocol RxLogPersisting: Actor { _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] ) async throws - /// Batch update `regionScope` on incoming channel `Message` rows - /// correlated by `(channelIndex, senderTimestamp)` + /// Batch update dual region fields on incoming channel `Message` rows + /// correlated by `(channelIndex, senderTimestamp)`. + /// - Returns: IDs of messages that were written (for open-chat invalidation). + @discardableResult func batchUpdateChannelMessageRegion( radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] - /// Batch update `regionScope` on incoming DM `Message` rows - /// correlated by `(senderPrefixByte, senderTimestamp)` + /// Batch update dual region fields on incoming DM `Message` rows + /// correlated by `(senderPrefixByte, senderTimestamp)`. + /// - Returns: IDs of messages that were written (for open-chat invalidation). + @discardableResult func batchUpdateDMMessageRegion( radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] } // MARK: - Default Parameter Values @@ -75,8 +99,12 @@ extension RxLogPersisting { try await fetchRxLogEntries(radioID: radioID, limit: 500) } - /// Prune RX log entries with the default retention cap of 1000 plus a 100-entry threshold + /// Prune RX log entries with the default retention cap plus threshold func pruneRxLogEntries(radioID: UUID) async throws { - try await pruneRxLogEntries(radioID: radioID, keepCount: 1000, pruneThreshold: 100) + try await pruneRxLogEntries( + radioID: radioID, + keepCount: RxLogRetention.keepCount, + pruneThreshold: RxLogRetention.pruneThreshold + ) } } diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index 6cccebf5b..8a2f7599a 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -1,6 +1,5 @@ import Foundation import MeshCore -import OSLog import SwiftData /// Dependency injection container for MC1Services. @@ -14,9 +13,11 @@ import SwiftData /// The container is per-connection, not a singleton: `ConnectionManager` builds a /// fresh `ServiceContainer` (and a fresh session) on every connection in /// `buildServicesAndSaveDevice`, and tears it down via `tearDown()` on disconnect -/// before nilling its reference. Anything that must survive reconnects (for example -/// detected platform or last-clean-sync state) lives on `ConnectionManager`, not here. -/// `init` also reassigns the `DebugLogBuffer.shared` global to this container's buffer, +/// before nilling its reference. The `PersistenceStore` is injected and +/// process-lifetime on `ConnectionManager`; it is not minted here. Anything +/// else that must survive reconnects (for example detected platform or +/// last-clean-sync state) lives on `ConnectionManager`, not here. `init` also +/// reassigns the `DebugLogBuffer.shared` global to this container's buffer, /// so a stale container's services must not keep running past teardown. /// /// ## Usage @@ -25,7 +26,7 @@ import SwiftData /// // Create container with session and model container /// let container = ServiceContainer( /// session: meshCoreSession, -/// modelContainer: modelContainer, +/// dataStore: persistenceStore, /// radioID: radioUUID /// ) /// @@ -205,7 +206,8 @@ public final class ServiceContainer { /// /// - Parameters: /// - session: The MeshCoreSession for device communication - /// - modelContainer: The SwiftData model container for persistence + /// - dataStore: Process-lifetime persistence store. Injected by + /// `ConnectionManager`; the container does not mint its own. /// - 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. @@ -219,7 +221,7 @@ public final class ServiceContainer { /// already-connected initial value as a fired edge. init( session: MeshCoreSession, - modelContainer: ModelContainer, + dataStore: PersistenceStore, radioID: UUID, appStateProvider: AppStateProvider? = nil, connectionStateEvents: EventBroadcaster? = nil, @@ -227,7 +229,7 @@ public final class ServiceContainer { ) { self.session = session self.appStateProvider = appStateProvider - dataStore = PersistenceStore(modelContainer: modelContainer) + self.dataStore = dataStore inlineImageDimensionsStore = InlineImageDimensionsStore() // Independent services (no dependencies) @@ -346,21 +348,7 @@ public final class ServiceContainer { 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)") - } + await heardRepeatsService.configure(radioID: radioID) // Start event monitoring for services that need it if enableAdvertisementMonitoring { @@ -370,10 +358,6 @@ public final class ServiceContainer { await messageService.startEventMonitoring() await messageService.startAckExpiryChecking() - let meshSession = session - await remoteNodeService.setRadioClockProvider { [weak meshSession] in - try? await meshSession?.getTime() - } await remoteNodeService.startEventMonitoring() // Always start message event monitoring so handlers are ready for polled messages @@ -427,9 +411,8 @@ public final class ServiceContainer { } /// 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. + /// so the chat send queue drains. `stopEventMonitoring()` alone does not + /// cover that. func tearDown() async { await stopEventMonitoring() @@ -499,9 +482,10 @@ extension ServiceContainer { radioID: UUID = UUID() ) async throws -> ServiceContainer { let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) return ServiceContainer( session: session, - modelContainer: container, + dataStore: store, radioID: radioID ) } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift index ac6225bdf..7e4a18719 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift @@ -12,6 +12,14 @@ public enum AdvertisementEvent: Sendable { /// A contact or discovered node was created or updated; observers should /// reload contact lists. case contactUpdated + /// Adopted orphan DMs created or updated a conversation row; observers must + /// refresh the conversation list (mirrors `SyncDataEvent.conversationsChanged`). + case conversationsChanged + /// Orphan DMs were linked to these contacts by a delta round. A DM that + /// arrived before its sender's contact existed had no contact to notify at + /// receipt; the owner of `NotificationService` posts the banner and refreshes + /// the badge for each contact when adoption resolves the sender. + case orphanDirectMessagesAdopted(contactIDs: [UUID]) /// A new contact was discovered via advertisement. case newContactDiscovered(name: String, contactID: UUID, contactType: ContactType) /// The device's node storage full state changed (true = full, false = has space). diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService+ContactFetchQueue.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService+ContactFetchQueue.swift deleted file mode 100644 index b08541f08..000000000 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService+ContactFetchQueue.swift +++ /dev/null @@ -1,277 +0,0 @@ -import Foundation -import MeshCore - -// MARK: - Contact Fetch Queue - -extension AdvertisementService { - /// Why a contact fetch was requested; determines post-fetch persistence and events. - enum ContactFetchReason { - case advert - case pathUpdate - } - - struct ContactFetchEntry { - var reason: ContactFetchReason - /// Captured at enqueue so teardown (which nils currentRadioID) can't misattribute work. - var radioID: UUID - } - - func enqueueContactFetch(_ publicKey: Data, reason: ContactFetchReason, radioID: UUID) { - if var existing = contactFetchQueue[publicKey] { - // Advert post-fetch work is a superset of pathUpdate's. - if reason == .advert { - existing.reason = .advert - } - existing.radioID = radioID - contactFetchQueue[publicKey] = existing - } else { - contactFetchQueue[publicKey] = ContactFetchEntry(reason: reason, radioID: radioID) - } - startContactFetchWorkerIfNeeded() - } - - func startContactFetchWorkerIfNeeded() { - guard contactFetchWorker == nil else { return } - guard !isSyncingContacts else { return } - guard !contactFetchQueue.isEmpty else { return } - - contactFetchWorkerGeneration &+= 1 - let generation = contactFetchWorkerGeneration - contactFetchWorker = Task { [weak self] in - await self?.runContactFetchWorker(generation: generation) - } - } - - func runContactFetchWorker(generation: UInt64) async { - // Each iteration is one snapshotted pass. Barrier waiters resume at pass - // boundaries so late joiners never await drain-until-empty under live traffic. - while !Task.isCancelled, !isSyncingContacts { - let passKeys = Set(contactFetchQueue.keys) - // Emptiness check and generation-matched nil share one actor-isolated - // region with no await between them (lost-wakeup handshake). - if passKeys.isEmpty { - break - } - - for publicKey in passKeys { - guard !Task.isCancelled else { break } - guard !isSyncingContacts else { break } - // 0x8F may have removed this entry after the pass was snapshotted. - guard contactFetchQueue[publicKey] != nil else { continue } - - await processContactFetch(publicKey: publicKey) - } - - // Always resume waiters at the pass boundary, including after throws. - resumeBarrierWaiters() - } - - // Only the active worker may clear the ref (generation match). - if contactFetchWorkerGeneration == generation { - contactFetchWorker = nil - } - resumeBarrierWaiters() - } - - /// True when 0x8F/teardown cancelled the airborne fetch, or the worker Task was cancelled. - var isCommitCancelled: Bool { - Task.isCancelled || inFlightCancelled - } - - /// Removes the queue entry only when this fetch was not cancelled mid-flight. - /// A 0x8F + re-enqueue may own the slot; wiping it would strand the newer request. - func removeQueueEntryUnlessSuperseded(_ publicKey: Data) { - if !inFlightCancelled { - contactFetchQueue.removeValue(forKey: publicKey) - } - } - - func processContactFetch(publicKey: Data) async { - let pubKeyHex = publicKey.uppercaseHexString() - - inFlightKey = publicKey - inFlightCancelled = false - defer { - if inFlightKey == publicKey { - inFlightKey = nil - } - } - - let meshContact: MeshContact? - do { - meshContact = try await session.getContact(publicKey: publicKey) - } catch { - logger.error("Failed to fetch contact: \(error.localizedDescription)") - discoverTrace.error("B2 getContact THREW key=\(pubKeyHex): \(error.localizedDescription)") - // Drop unless a re-enqueue already owns the slot; later adverts re-enqueue. - removeQueueEntryUnlessSuperseded(publicKey) - return - } - - if isCommitCancelled { - // Task cancel without re-enqueue can leave a stale entry; stop clears the queue. - removeQueueEntryUnlessSuperseded(publicKey) - return - } - - guard let meshContact else { - discoverTrace.notice("B2 getContact returned nil key=\(pubKeyHex)") - removeQueueEntryUnlessSuperseded(publicKey) - return - } - - // Re-read under the actor: advert may have upgraded a pathUpdate entry mid-flight. - guard let entry = contactFetchQueue[publicKey] else { - return - } - let reason = entry.reason - let radioID = entry.radioID - - let frame = meshContact.toContactFrame() - - switch reason { - case .advert: - await commitAdvertFetch( - publicKey: publicKey, - pubKeyHex: pubKeyHex, - meshContact: meshContact, - frame: frame, - radioID: radioID - ) - case .pathUpdate: - await commitPathUpdateFetch( - publicKey: publicKey, - pubKeyHex: pubKeyHex, - meshContact: meshContact, - frame: frame, - radioID: radioID - ) - // Advert may have upgraded the queue entry while pathUpdate save was airborne. - if !isCommitCancelled, - let upgraded = contactFetchQueue[publicKey], - upgraded.reason == .advert { - await commitAdvertFetch( - publicKey: publicKey, - pubKeyHex: pubKeyHex, - meshContact: meshContact, - frame: frame, - radioID: upgraded.radioID - ) - } - } - - // After cancel, any remaining entry belongs to a newer enqueue. - if !isCommitCancelled { - contactFetchQueue.removeValue(forKey: publicKey) - } - } - - func commitAdvertFetch( - publicKey: Data, - pubKeyHex: String, - meshContact: MeshContact, - frame: ContactFrame, - radioID: UUID - ) async { - if isCommitCancelled { return } - - do { - let saveResult = try await dataStore.saveContact(radioID: radioID, from: frame) - if isCommitCancelled { - await rollbackInsertIfNeeded(saveResult) - return - } - - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - if isCommitCancelled { - await rollbackInsertIfNeeded(saveResult) - return - } - discoverTrace.info("B2 0x80 getContact OK upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 0x80 getContact-path upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - if isCommitCancelled { - await rollbackInsertIfNeeded(saveResult) - return - } - } - - // Empty names pass through; NotificationService supplies a localized fallback. - let contactName = meshContact.advertisedName - let contactType = meshContact.type - if saveResult.isNew { - eventBroadcaster.yield(.newContactDiscovered( - name: contactName, - contactID: saveResult.id, - contactType: contactType - )) - logOverwriteReplacementIfRecent( - newContactName: contactName.isEmpty ? "Unknown Contact" : contactName, - newContactType: contactType - ) - } else { - // Already-synced row (deferred or repeat fetch) — UI refresh only. - eventBroadcaster.yield(.contactUpdated) - } - } catch { - logger.error("Failed to save fetched contact: \(error.localizedDescription)") - discoverTrace.error("B2 0x80 saveContact FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - } - - func commitPathUpdateFetch( - publicKey: Data, - pubKeyHex: String, - meshContact: MeshContact, - frame: ContactFrame, - radioID: UUID - ) async { - if isCommitCancelled { return } - - do { - let saveResult = try await dataStore.saveContact(radioID: radioID, from: frame) - if isCommitCancelled { - // Insert-only rollback; never cascade-delete messages on an existing upsert. - await rollbackInsertIfNeeded(saveResult) - return - } - - logger.debug("Refreshed contact path: \(meshContact.advertisedName.isEmpty ? "unnamed" : meshContact.advertisedName)") - eventBroadcaster.yield(.contactUpdated) - } catch { - logger.error("Error refreshing contact path: \(error.localizedDescription)") - } - } - - /// Rolls back only when this fetch inserted the row. Routes through - /// `deleteContactIfUnreferenced` so probe and delete share one store - /// isolation region and probe errors fail closed. - func rollbackInsertIfNeeded(_ saveResult: (id: UUID, isNew: Bool)) async { - guard saveResult.isNew else { return } - do { - try await dataStore.deleteContactIfUnreferenced(id: saveResult.id) - } catch { - logger.warning("Insert-only rollback failed for \(saveResult.id): \(error.localizedDescription)") - } - } - - func resumeBarrierWaiters() { - let waiters = barrierWaiters - barrierWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - } - - func cancelContactFetchWorkerAndClearQueue() { - contactFetchWorker?.cancel() - // Bump generation so a late worker exit cannot nil a restarted worker. - contactFetchWorkerGeneration &+= 1 - contactFetchWorker = nil - contactFetchQueue.removeAll() - inFlightCancelled = true - inFlightKey = nil - resumeBarrierWaiters() - } -} diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService+DeltaSync.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService+DeltaSync.swift new file mode 100644 index 000000000..2f7a132dd --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService+DeltaSync.swift @@ -0,0 +1,495 @@ +import Foundation +import MeshCore + +// MARK: - Debounced Contact Delta Sync + +extension AdvertisementService { + func scheduleDeltaSync() { + guard deltaSyncTask == nil else { return } + deltaSyncGeneration &+= 1 + let generation = deltaSyncGeneration + var fireAt = ContinuousClock.now + advertSyncDebounce + if let lastDeltaSyncEnd { + fireAt = max(fireAt, lastDeltaSyncEnd + advertSyncMinInterval) + } + deltaSyncTask = Task { [weak self] in + await self?.runDeltaSync(fireAt: fireAt, generation: generation) + } + } + + /// Clears `deltaSyncTask` only when this round still owns the registration. + /// A cancelled round that resumes after a re-arm must not wipe the new task. + func finishRound(generation: UInt64) { + guard deltaSyncGeneration == generation else { return } + deltaSyncTask = nil + } + + func runDeltaSync(fireAt: ContinuousClock.Instant, generation: UInt64) async { + do { + try await Task.sleep(until: fireAt, clock: .continuous) + } catch { + finishRound(generation: generation) + return + } + + guard !Task.isCancelled, let handler = deltaSyncHandler else { + finishRound(generation: generation) + return + } + guard !isSyncingContacts else { + finishRound(generation: generation) + return + } + + // Empty-round guard before drain so a schedule with no work never clears a + // pending 0x8F set or spends a radio round. Must run before + // `contactsDeletedDuringSync.removeAll()`. + guard hasPendingDeltaSyncWork else { + finishRound(generation: generation) + return + } + + // Capture before teardown clears `currentRadioID` so rollback can still run + // after `stopEventMonitoring`. + let radioID = currentRadioID + + let drained = pendingAdvertKeys + pendingAdvertKeys.removeAll() + let drainedReceiveTimes = takeAdvertReceiveTimes(for: drained) + let drainedPathKeys = pendingPathKeys + pendingPathKeys.removeAll() + let fullRefetch = escalateToFullRefetch + escalateToFullRefetch = false + // Clear before the commit so only mid-round deletes race this round's write path. + contactsDeletedDuringSync.removeAll() + + // Pre-round key set gates .newContactDiscovered and escalation only. + // A failed read must not invent notifications (prefer miss over false + // notify). Adoption is separate: it uses every drained key that has a row + // after the exchange so a snapshot failure cannot leave DMs orphaned. + let preRoundKnownKeys: Set? + if let radioID { + do { + preRoundKnownKeys = try await dataStore.fetchContactPublicKeys(radioID: radioID) + } catch { + logger.error( + "Contact key snapshot failed; suppressing new-contact notifications this round: \(error.localizedDescription)" + ) + preRoundKnownKeys = nil + } + } else { + preRoundKnownKeys = nil + } + let unknownAtStart: Set = if let preRoundKnownKeys { + drained.subtracting(preRoundKnownKeys) + } else { + [] + } + + // Snapshot lastModified for known path keys so a successful incremental + // that returns nothing (radio lastmod ≤ watermark) is detectable. + let preRoundPathLastModified = await snapshotPathLastModified( + keys: drainedPathKeys, radioID: radioID + ) + + let outcome = await handler(fullRefetch) + + // Rollback before the teardown guard: a failed or cancelled round may already + // have committed early batches, and an incremental sync never prunes them. + await rollBackContactsDeletedDuringSync(radioID: radioID) + + guard !Task.isCancelled, deltaSyncHandler != nil else { + finishRound(generation: generation) + return + } + + switch outcome { + case .busy: + // The radio was never asked, so this round keeps the failure budget intact. + // Do not stamp lastDeltaSyncEnd: a busy poll must not inherit the full min + // interval. A short busy-specific backoff re-arms below. + logger.info("Advert delta sync deferred: another sync holds the claim") + finishRound(generation: generation) + requeueDrainedWork( + drained, pathKeys: drainedPathKeys, fullRefetch: fullRefetch, shouldSchedule: false + ) + scheduleBusyRetry() + return + + case .notReady: + // Permanent until a full contact fetch succeeds. Neither requeue nor spend budget. + logger.notice( + "Advert delta sync not ready: dropping \(drained.count) pending key(s) until a full contact fetch completes" + ) + finishRound(generation: generation) + return + + case .failed: + consecutiveDeltaSyncFailures += 1 + lastDeltaSyncEnd = .now + finishRound(generation: generation) + + guard consecutiveDeltaSyncFailures < Self.maxConsecutiveDeltaSyncFailures else { + // Drop the drained work and wait for new adverts rather than retry an + // exchange the handler cannot complete. + // + // Path keys and escalateToFullRefetch were consumed into locals above; + // restore them so a pending path update or owed escalation is not + // silently dropped. Drained advert keys stay dropped — that is the + // cap's purpose. + logger.error( + "Advert delta sync failed \(Self.maxConsecutiveDeltaSyncFailures) times in a row; dropping \(drained.count) pending key(s)" + ) + consecutiveDeltaSyncFailures = 0 + // A 0x80/0x81 that arrived during this failing round already spent its + // one scheduleDeltaSync no-op against the still-set task, so no later + // event re-arms it. Detect that fresh work before restoring the round's + // own keys and re-arm for it; the min interval keeps it from spinning. + let freshWorkArrivedMidRound = hasPendingDeltaSyncWork + for key in drainedPathKeys where !contactsDeletedDuringSync.contains(key) { + pendingPathKeys.insert(key) + } + if fullRefetch { + escalateToFullRefetch = true + } + if freshWorkArrivedMidRound { + scheduleDeltaSync() + } + return + } + + requeueDrainedWork(drained, pathKeys: drainedPathKeys, fullRefetch: fullRefetch) + return + + case .synced: + break + } + + consecutiveDeltaSyncFailures = 0 + // Stamp phone recency for drained 0x80 keys that now have a row. Unknown + // contacts get lastHeard = 0 from Contact(radioID:from:); without this a + // stale radio lastModified makes matchesStaleNodePrune true right after hear. + await stampAdvertReceiveTimes(drainedReceiveTimes, radioID: radioID) + // Only keys proven absent before the round are announced as new. + let insertedKeysForNotify: Set = if let preRoundKnownKeys { + drained.subtracting(preRoundKnownKeys) + } else { + [] + } + await reconcile(drained, insertedKeys: insertedKeysForNotify) + // Adopt against every drained key that now has a row — not only + // insertedKeysForNotify — so a snapshot failure still links orphan DMs. + let adoptedContactIDs = await adoptOrphanedMessages(for: drained, radioID: radioID) + eventBroadcaster.yield(.contactUpdated) + // A non-nil lastMessageDate after adoption is a new/updated conversation + // row; contactsVersion alone does not reload the mounted chat list. The + // adopted DMs never notified at receipt (no contact then), so hand the + // contacts to the NotificationService owner for the banner and badge. + if !adoptedContactIDs.isEmpty { + eventBroadcaster.yield(.conversationsChanged) + eventBroadcaster.yield(.orphanDirectMessagesAdopted(contactIDs: adoptedContactIDs)) + } + + if !fullRefetch { + await escalateMissingUnknownKeys(unknownAtStart: unknownAtStart) + await escalateUndeliveredPathUpdates( + drainedPathKeys: drainedPathKeys, + preRoundLastModified: preRoundPathLastModified + ) + } else { + await dropStillMissingUnknownKeys(unknownAtStart: unknownAtStart) + } + + lastDeltaSyncEnd = .now + finishRound(generation: generation) + if hasPendingDeltaSyncWork { + scheduleDeltaSync() + } + } + + /// Re-arms after a busy outcome with a short backoff so performResync-style + /// claim holds do not spin at zero interval. + private func scheduleBusyRetry() { + guard deltaSyncTask == nil else { return } + guard hasPendingDeltaSyncWork else { return } + deltaSyncGeneration &+= 1 + let generation = deltaSyncGeneration + let fireAt = ContinuousClock.now + advertSyncBusyBackoff + deltaSyncTask = Task { [weak self] in + await self?.runDeltaSync(fireAt: fireAt, generation: generation) + } + } + + /// Returns a round's drained work to the pending set and re-arms the timer. + /// A rolled-back key stays dropped: refetching cannot bring it back. + /// Pass `shouldSchedule: false` when the caller re-arms with a different backoff. + private func requeueDrainedWork( + _ drained: Set, + pathKeys: Set, + fullRefetch: Bool, + shouldSchedule: Bool = true + ) { + // Keep a consumed full-refetch flag so a flaky escalated round-trip + // retries as full refetch rather than incremental. + if fullRefetch { + escalateToFullRefetch = true + } + for key in drained where !contactsDeletedDuringSync.contains(key) { + recordPendingAdvertKey(key) + } + for key in pathKeys where !contactsDeletedDuringSync.contains(key) { + pendingPathKeys.insert(key) + } + if shouldSchedule, hasPendingDeltaSyncWork { + scheduleDeltaSync() + } + } + + /// Removes contacts the radio deleted (0x8F) since this round drained its keys. + /// Runs before `reconcile` so a batch re-save cannot leave a Discover row for a + /// deleted contact. The keys stay recorded: reconcile and escalation read them. + /// + /// A key that re-advertised after the delete never reaches here: the radio + /// re-added it, so `recordPendingAdvertKey` clears the tombstone and the + /// re-created row is legitimate. A later delete re-arms the tombstone, so the + /// last event the radio sent always decides. + /// + /// Uses `deleteContactIfUnreferenced` so a concurrent DM attached after the + /// batch re-save is kept. Prefer an orphan contact over a cascade wipe. + private func rollBackContactsDeletedDuringSync(radioID: UUID?) async { + let keys = contactsDeletedDuringSync + guard let radioID, !keys.isEmpty else { return } + + for publicKey in keys { + let pubKeyHex = publicKey.uppercaseHexString() + do { + guard let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) else { continue } + try await dataStore.deleteContactIfUnreferenced(id: contact.id) + logger.info("Delta sync rollback: removed contact \(pubKeyHex) deleted by the radio mid-sync") + eventBroadcaster.yield(.contactUpdated) + } catch { + logger.error("Delta sync rollback failed for \(pubKeyHex): \(error.localizedDescription)") + } + } + } + + /// Refreshes Discover rows for drained 0x80 keys and emits new-contact + /// notifications only for keys this round actually inserted. + func reconcile(_ drained: Set, insertedKeys: Set) async { + guard let radioID = currentRadioID else { return } + + for publicKey in drained { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + let pubKeyHex = publicKey.uppercaseHexString() + do { + guard let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) else { + discoverTrace.info("B2 reconcile: no contact yet key=\(pubKeyHex)") + continue + } + // Re-check after each store hop; the radio can delete mid-round. + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + + // Use stored radio fields verbatim; do not stamp phone clock into + // lastAdvertTimestamp / lastModified (those remain radio-sourced). + let frame = ContactFrame( + publicKey: contact.publicKey, + type: contact.type, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + name: contact.name, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified + ) + + do { + let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + discoverTrace.info("B2 reconcile upsert key=\(pubKeyHex) isNew=\(isNew)") + } catch { + discoverTrace.error("B2 reconcile upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") + } + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + + // Prefer losing a notification over inventing one: only keys absent from + // the pre-round snapshot and present after the exchange are announced. + if insertedKeys.contains(publicKey) { + eventBroadcaster.yield(.newContactDiscovered( + name: contact.name, + contactID: contact.id, + contactType: contact.type + )) + logOverwriteReplacementIfRecent( + newContactName: contact.name, + newContactType: contact.type + ) + } + } catch { + logger.error("Reconcile failed for \(pubKeyHex): \(error.localizedDescription)") + } + } + } + + /// Links orphaned DMs whose sender prefix matches contacts among `keys`. + /// `keys` is the drained set for this round (not only newly inserted keys) so + /// a failed pre-round snapshot cannot disable adoption. Springboard badge is + /// not updated here; the NotificationService owner refreshes it and posts the + /// banner from `.orphanDirectMessagesAdopted`. + /// + /// Returns the contact ids that adopted at least one message, so the caller + /// can emit `.conversationsChanged` and `.orphanDirectMessagesAdopted`. + private func adoptOrphanedMessages(for keys: Set, radioID: UUID?) async -> [UUID] { + guard let radioID, !keys.isEmpty else { return [] } + + var contacts: [(id: UUID, publicKey: Data)] = [] + contacts.reserveCapacity(keys.count) + for publicKey in keys { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + do { + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + contacts.append((contact.id, contact.publicKey)) + } + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error("Adoption contact lookup failed for \(pubKeyHex): \(error.localizedDescription)") + } + } + guard !contacts.isEmpty else { return [] } + + do { + let adopted = try await dataStore.adoptOrphanedDirectMessages( + radioID: radioID, contacts: contacts + ) + if !adopted.isEmpty { + logger.info("Adopted orphaned DMs for \(adopted.count) contact(s)") + } + return Array(adopted.keys) + } catch { + logger.error("Orphaned DM adoption failed: \(error.localizedDescription)") + return [] + } + } + + /// After a successful incremental sync, escalate once when keys that were + /// unknown at round start still lack a row. Keys present in the pre-round + /// snapshot stay out of scope (known contacts, including local deletes). + private func escalateMissingUnknownKeys(unknownAtStart: Set) async { + guard let radioID = currentRadioID else { return } + + var missing: Set = [] + for publicKey in unknownAtStart { + // A key the radio deleted is missing on purpose; refetching cannot bring it back. + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + guard let exists = await contactExists(radioID: radioID, publicKey: publicKey) else { continue } + if !exists { + missing.insert(publicKey) + } + } + guard !missing.isEmpty else { return } + + logger.info("Advert delta sync escalation: \(missing.count) unknown key(s) still missing") + escalateToFullRefetch = true + for key in missing { + recordPendingAdvertKey(key) + } + } + + /// Pre-round `lastModified` for known path keys. Keys with no local row are + /// omitted; post-round absence still counts as undelivered. + private func snapshotPathLastModified( + keys: Set, radioID: UUID? + ) async -> [Data: UInt32] { + guard let radioID, !keys.isEmpty else { return [:] } + var snapshot: [Data: UInt32] = [:] + snapshot.reserveCapacity(keys.count) + for publicKey in keys { + do { + if let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) { + snapshot[publicKey] = contact.lastModified + } + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error( + "Path lastModified snapshot failed for \(pubKeyHex): \(error.localizedDescription)" + ) + } + } + return snapshot + } + + /// After a successful incremental sync driven by 0x81, escalate once when a + /// path key was not refreshed. Firmware sets `lastmod` on path recv and only + /// reports contacts with `lastmod > since`, so a radio RTC reset or phone + /// clock step-back that lands the new lastmod at or below the stored + /// watermark returns an empty incremental stream. Known contacts never enter + /// `escalateMissingUnknownKeys`, so without this check out-path and + /// coordinates stay stale. One prune-free full refetch recovers; no per-key + /// `getContact` loop. + private func escalateUndeliveredPathUpdates( + drainedPathKeys: Set, + preRoundLastModified: [Data: UInt32] + ) async { + guard let radioID = currentRadioID, !drainedPathKeys.isEmpty else { return } + + var undelivered = 0 + for publicKey in drainedPathKeys { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + do { + guard let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) else { + // Path key with no local row and no insert this round. + undelivered += 1 + continue + } + if let before = preRoundLastModified[publicKey], contact.lastModified <= before { + undelivered += 1 + } + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error( + "Path delivery check failed for \(pubKeyHex): \(error.localizedDescription)" + ) + } + } + guard undelivered > 0 else { return } + + logger.info( + "Advert path update escalation: \(undelivered) path key(s) not refreshed by incremental fetch" + ) + escalateToFullRefetch = true + } + + /// After an escalated full refetch, log keys that are still missing (no loop). + private func dropStillMissingUnknownKeys(unknownAtStart: Set) async { + guard let radioID = currentRadioID else { return } + + for publicKey in unknownAtStart { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + guard let exists = await contactExists(radioID: radioID, publicKey: publicKey) else { continue } + if !exists { + let pubKeyHex = publicKey.uppercaseHexString() + logger.info("Advert full refetch still missing key=\(pubKeyHex); dropping") + } + } + } + + /// Whether a Contact row exists locally, or nil when the store read failed. + /// Nil is not evidence the radio lacks the contact; callers must not escalate on nil. + private func contactExists(radioID: UUID, publicKey: Data) async -> Bool? { + do { + return try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) != nil + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error("Contact lookup failed for \(pubKeyHex): \(error.localizedDescription)") + return nil + } + } +} diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift index 28fe95651..c43a7638d 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift @@ -22,6 +22,21 @@ extension AdvertisementError: LocalizedError { } } +// MARK: - Advert Contact Sync Outcome + +/// Result of one advert-driven contact delta exchange. +/// +/// `busy` is not `failed`: a collision with another sync never reaches the +/// radio, so it must not spend the failure budget that drops drained keys. +/// `notReady` is permanent until a full contact fetch succeeds: neither requeue +/// nor spend the failure budget. +public enum AdvertContactSyncOutcome: Sendable { + case synced + case busy + case failed + case notReady +} + // MARK: - Advertisement Service /// Service for managing device advertisements and discovery. @@ -38,28 +53,58 @@ public actor AdvertisementService { let dataStore: any PersistenceStoreProtocol private var eventMonitorTask: Task? - private var currentRadioID: UUID? + var currentRadioID: UUID? - /// When true, the contact-fetch worker is paused (e.g. during contact sync). + /// When true, advert delta sync is deferred (full or manual contact sync). + /// Manual pull-to-refresh also sets this; it is invisible to `SyncCoordinator.isSyncInProgress`. var isSyncingContacts = false - /// Pending `getContact` work, deduped by key. Advert reason wins on merge - /// because its post-fetch work is a superset of pathUpdate's. - var contactFetchQueue: [Data: ContactFetchEntry] = [:] - var contactFetchWorker: Task? - /// Bumped when a worker starts; an exiting worker nils the ref only when - /// generations match so it cannot clobber a newly started worker. - var contactFetchWorkerGeneration: UInt64 = 0 + /// 0x80 keys heard since the last delta sync. + var pendingAdvertKeys: Set = [] + + /// Phone receive times for pending 0x80 keys, keyed by public key. + /// Stamped onto `lastHeardTimestamp` after a delta insert (or materialize) + /// so a stale radio RTC cannot prune a contact just heard on air. + private var pendingAdvertReceiveTimes: [Data: Date] = [:] - /// Key currently being fetched, and whether 0x8F or teardown cancelled it - /// mid-flight. A cancelled fetch must not commit. - var inFlightKey: Data? - var inFlightCancelled = false + /// 0x81 path-update keys waiting for the next delta sync. Tracked apart from + /// `pendingAdvertKeys` because a path key must not create a Discover row. + /// Keys (not a bool) so a successful incremental round can verify delivery + /// when radio `lastmod` falls at or below the stored watermark. + var pendingPathKeys: Set = [] + + /// True when any 0x81 path key is waiting for delta sync. + var pathSyncPending: Bool { + !pendingPathKeys.isEmpty + } - /// Waiters for the current snapshotted worker pass. Concurrent - /// `setSyncingContacts(false)` callers share one pass; late joiners wait - /// for the next pass only — never drain-until-empty under live enqueues. - var barrierWaiters: [CheckedContinuation] = [] + /// Keys the radio deleted (0x8F) since this round drained its keys. Rolled back so + /// a batch cannot re-save a dropped row, and skipped while reconciling. Cleared + /// when the next round drains, so a between-round delete never undoes a later + /// re-created contact. + var contactsDeletedDuringSync: Set = [] + + var deltaSyncTask: Task? + /// Monotonic identity for the scheduled delta-sync round. `finishRound` clears + /// `deltaSyncTask` only while this still matches the running round. + var deltaSyncGeneration: UInt64 = 0 + var lastDeltaSyncEnd: ContinuousClock.Instant? + var deltaSyncHandler: (@Sendable (_ fullRefetch: Bool) async -> AdvertContactSyncOutcome)? + /// One-shot: the next delta sync runs as a prune-free full fetch. + var escalateToFullRefetch = false + + /// Delta syncs that failed back to back without an intervening success. + var consecutiveDeltaSyncFailures = 0 + + /// Failed delta syncs tolerated before a round drops its drained keys. Without a + /// cap, a handler that never succeeds repeats a full contact exchange forever. + static let maxConsecutiveDeltaSyncFailures = 5 + + let advertSyncDebounce: Duration + let advertSyncMinInterval: Duration + /// Backoff before re-arming after a `.busy` outcome. Shorter than + /// `advertSyncMinInterval` so claim collisions poll cheaply without spinning. + let advertSyncBusyBackoff: Duration /// Last overwrite-oldest deletion, used to correlate the replacement advert (0x8F then new contact). private var lastOverwriteDeletion: (name: String, pubKeyHex: String, time: Date)? @@ -70,18 +115,25 @@ public actor AdvertisementService { // MARK: - Initialization - public init(session: any AdvertisingSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { + public init( + session: any AdvertisingSessionOps & SessionEventStreaming, + dataStore: any PersistenceStoreProtocol, + advertSyncDebounce: Duration = .seconds(5), + advertSyncMinInterval: Duration = .seconds(30), + advertSyncBusyBackoff: Duration = .seconds(5) + ) { self.session = session self.dataStore = dataStore + self.advertSyncDebounce = advertSyncDebounce + self.advertSyncMinInterval = advertSyncMinInterval + self.advertSyncBusyBackoff = advertSyncBusyBackoff } - /// Cancels outstanding tasks only. Full queue and barrier teardown is - /// ``stopEventMonitoring`` (`ServiceContainer.tearDown`); deinit cannot - /// safely resume waiters. Commit paths also treat `Task.isCancelled` as - /// cancel eligibility so work does not land after teardown begins. + /// Cancels outstanding tasks only. Full teardown is ``stopEventMonitoring`` + /// (`ServiceContainer.tearDown`); deinit cannot safely clear the handler. deinit { eventMonitorTask?.cancel() - contactFetchWorker?.cancel() + deltaSyncTask?.cancel() } // MARK: - Events @@ -130,32 +182,153 @@ public actor AdvertisementService { } } - /// Stops event monitoring and tears down the contact-fetch worker - /// (queue, in-flight cancel, barrier waiters). Sole full teardown path. + /// Stops event monitoring and clears advert delta-sync state. public func stopEventMonitoring() { eventMonitorTask?.cancel() eventMonitorTask = nil currentRadioID = nil - cancelContactFetchWorkerAndClearQueue() + deltaSyncTask?.cancel() + deltaSyncTask = nil + deltaSyncHandler = nil + pendingAdvertKeys.removeAll() + pendingPathKeys.removeAll() + pendingAdvertReceiveTimes.removeAll() + escalateToFullRefetch = false + consecutiveDeltaSyncFailures = 0 + isSyncingContacts = false + // Keep `contactsDeletedDuringSync` across teardown so a commit still in flight + // can roll back rows the radio deleted; the next round clears it when it drains. } - /// Toggle deferred contact fetching during sync. + /// Records a 0x80 key for the next delta sync. /// - /// When `false`, starts the single contact-fetch worker (if idle) and awaits - /// one snapshotted pass — not drain-until-empty. Concurrent callers share the - /// same in-flight pass; callers that join after a pass boundary wait for the - /// next snapshotted pass only. + /// A re-advert for a key in `contactsDeletedDuringSync` means the radio + /// re-added the contact after a mid-round 0x8F. Clear the tombstone so + /// rollback, reconcile, and adoption treat the re-synced row as live. + /// + /// `receivedAt` is the phone clock at the 0x80 (or re-record). It is applied + /// to `lastHeardTimestamp` once a Contact row exists so prune recency is not + /// hostage to a stale radio RTC. + func recordPendingAdvertKey(_ key: Data, receivedAt: Date = Date()) { + pendingAdvertKeys.insert(key) + contactsDeletedDuringSync.remove(key) + pendingAdvertReceiveTimes[key] = receivedAt + } + + /// Captures and removes phone receive times for keys about to drain. + func takeAdvertReceiveTimes(for keys: Set) -> [Data: Date] { + var times: [Data: Date] = [:] + times.reserveCapacity(keys.count) + for key in keys { + if let receivedAt = pendingAdvertReceiveTimes.removeValue(forKey: key) { + times[key] = receivedAt + } + } + return times + } + + /// Stamps `lastHeardTimestamp` for drained 0x80 keys that now have a Contact + /// row, using each key's recorded phone receive time. Uses + /// `touchContactHeard` so clamping matches the live 0x80 path. + func stampAdvertReceiveTimes(_ receiveTimes: [Data: Date], radioID: UUID?) async { + guard let radioID, !receiveTimes.isEmpty else { return } + for (publicKey, receivedAt) in receiveTimes { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + do { + _ = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt + ) + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error( + "Post-delta lastHeard stamp failed for \(pubKeyHex): \(error.localizedDescription)" + ) + } + } + } + + /// True when a delta sync still has work: pending 0x80 keys, a path update, + /// or an owed prune-free full refetch. Re-arm sites and the empty-round + /// guard share this definition so a future flag cannot desynchronise them. + var hasPendingDeltaSyncWork: Bool { + !pendingAdvertKeys.isEmpty || pathSyncPending || escalateToFullRefetch + } + + /// Creates a local `Contact` from a pending 0x80 key so a DM that arrives + /// inside the advert delta-sync debounce window can notify and list normally. + /// + /// Matches `prefix` against `pendingAdvertKeys` only (keys the radio already + /// auto-added). Exactly one match is required; ambiguous prefixes return nil. + /// The key stays pending so the debounced delta round still reconciles Discover + /// and may emit `.newContactDiscovered`. + /// + /// - Returns: The persisted contact, or nil when no unique pending key matches + /// or the radio fetch / save fails. + public func materializeContactForPendingAdvert( + matchingPrefix prefix: Data, + radioID: UUID + ) async -> ContactDTO? { + guard !prefix.isEmpty else { return nil } + + let matches = pendingAdvertKeys.filter { $0.starts(with: prefix) } + guard matches.count == 1, let publicKey = matches.first else { return nil } + + let pubKeyHex = publicKey.uppercaseHexString() + do { + guard let meshContact = try await session.getContact(publicKey: publicKey) else { + logger.info("materialize pending advert: getContact nil key=\(pubKeyHex)") + return nil + } + let frame = meshContact.toContactFrame() + let saveResult = try await dataStore.saveContact(radioID: radioID, from: frame) + // Stamp phone recency before the returned DTO is read so a debounce-window + // insert is not prune-eligible under a stale radio lastModified. + let receivedAt = pendingAdvertReceiveTimes[publicKey] ?? Date() + do { + _ = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt + ) + } catch { + logger.error( + "materialize pending advert lastHeard stamp failed key=\(pubKeyHex): \(error.localizedDescription)" + ) + } + do { + _ = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + } catch { + logger.error( + "materialize pending advert Discover upsert failed key=\(pubKeyHex): \(error.localizedDescription)" + ) + } + eventBroadcaster.yield(.contactUpdated) + return try await dataStore.fetchContact(id: saveResult.id) + } catch { + logger.error( + "materialize pending advert failed key=\(pubKeyHex): \(error.localizedDescription)" + ) + return nil + } + } + + /// Installs the contact delta-sync handler used after debounced 0x80/0x81 events. + /// A non-nil handler re-arms when work is already pending (adverts, path updates, + /// or an owed full refetch that arrived before wiring). + public func setDeltaSyncHandler( + _ handler: (@Sendable (_ fullRefetch: Bool) async -> AdvertContactSyncOutcome)? + ) { + deltaSyncHandler = handler + if handler != nil, hasPendingDeltaSyncWork { + scheduleDeltaSync() + } + } + + /// Toggle deferred contact sync during a full or manual contact sync. + /// When set to `false` with pending work, re-arms so a mid-sync debounce does + /// not drop keys, path updates, or an owed full refetch. public func setSyncingContacts(_ isSyncing: Bool) async { isSyncingContacts = isSyncing - if !isSyncing { - startContactFetchWorkerIfNeeded() - await withCheckedContinuation { (continuation: CheckedContinuation) in - barrierWaiters.append(continuation) - // Idle worker means no pass is running; resume immediately. - if contactFetchWorker == nil { - resumeBarrierWaiters() - } - } + if !isSyncing, hasPendingDeltaSyncWork { + scheduleDeltaSync() } } @@ -238,49 +411,49 @@ public actor AdvertisementService { // MARK: - Private Event Handlers - /// Handle advertisement event - Existing contact updated + /// 0x80 change notification: the radio already updated a contact row. private func handleAdvertEvent(publicKey: Data, radioID: UUID) async { let pubKeyHex = publicKey.uppercaseHexString() logger.debug("Advert event for \(pubKeyHex)") discoverTrace.info("B1 0x80 ADVERT received key=\(pubKeyHex)") - let timestamp = UInt32(Date().timeIntervalSince1970) + // One phone clock for touch and pending receive-time so the post-delta + // stamp matches the air-hear moment (not a later debounce fire). + let receivedAt = Date() + // Retry touch once: a transient store error must not permanently drop the + // advert under the empty-round guard. Recording without a successful touch + // is also wrong (it can announce a long-known contact as new). + var known: Bool? do { - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - 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) + known = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt + ) + } catch { + logger.error("Error handling advert event (retrying once): \(error.localizedDescription)") + do { + known = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt ) - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Keep DiscoveredNode in sync so Discover stays visible for known contacts. - 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)") - } + } catch { + logger.error("Advert touch retry failed: \(error.localizedDescription)") + known = nil + } + } + if let known { + if known { eventBroadcaster.yield(.contactUpdated) } else { discoverTrace.info("B2 0x80 no local contact key=\(pubKeyHex) syncing=\(isSyncingContacts)") - // Device has the contact, local store does not (auto-add). Enqueue - // getContact; never await BLE on the event-handler path. - logger.info("ADVERT received for unknown contact - enqueueing fetch") - enqueueContactFetch(publicKey, reason: .advert, radioID: radioID) + logger.info("ADVERT received for unknown contact - scheduling delta sync") } - } catch { - logger.error("Error handling advert event: \(error.localizedDescription)") + recordPendingAdvertKey(publicKey, receivedAt: receivedAt) } + + // Schedule so other pending keys or a path update still run; the empty-round + // guard no-ops when this advert alone failed both touch attempts. + scheduleDeltaSync() } /// Handle new advertisement event - New contact discovered (manual add mode) @@ -308,15 +481,20 @@ public actor AdvertisementService { } } - /// Path changed: enqueue getContact rather than awaiting BLE on the event path. - /// Shared worker defers while `isSyncingContacts` is true. + /// Path changed on the radio: record the key and schedule contact delta sync. + /// Path keys must not create Discover rows; path changes are not on-air heard evidence. + /// Delivery is via watermark-filtered GET_CONTACTS; if radio `lastmod` is at or + /// below the stored watermark the incremental round returns nothing and the + /// post-round path check escalates once to a prune-free full refetch. private func handlePathUpdatedEvent(publicKey: Data, radioID: UUID) async { let pubKeyHex = publicKey.uppercaseHexString() logger.debug("Path updated event for \(pubKeyHex)") - enqueueContactFetch(publicKey, reason: .pathUpdate, radioID: radioID) + pendingPathKeys.insert(publicKey) + scheduleDeltaSync() } - /// Handle path discovery response event + /// Path discovery response: update out-path and stamp phone-clock lastHeard. + /// Leaves radio lastModified unchanged — it is a radio watermark, not phone time. 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 @@ -348,9 +526,21 @@ public actor AdvertisementService { lastAdvertTimestamp: contact.lastAdvertTimestamp, latitude: contact.latitude, longitude: contact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) + lastModified: contact.lastModified ) _ = try await dataStore.saveContact(radioID: radioID, from: frame) + + do { + _ = try await dataStore.touchContactHeard( + radioID: radioID, + publicKey: contact.publicKey, + at: Date() + ) + } catch { + logger.error( + "Path response lastHeard stamp failed: \(error.localizedDescription)" + ) + } } eventBroadcaster.yield(.pathDiscoveryResponse(result)) @@ -381,13 +571,14 @@ public actor AdvertisementService { return } - // Cancel in-flight / queued fetches for this key before the local-row guard. - // Unknown-key fetches have no local row; without cancel a late save can - // resurrect the contact after 0x8F. - contactFetchQueue.removeValue(forKey: publicKey) - if inFlightKey == publicKey { - inFlightCancelled = true - } + // Drop pending reconcile / path keys so a deleted contact is not re-notified + // and a surviving path key cannot escalate to an epoch-0 full refetch. + pendingAdvertKeys.remove(publicKey) + pendingPathKeys.remove(publicKey) + pendingAdvertReceiveTimes.removeValue(forKey: publicKey) + // A running delta sync can re-save or reconcile this row after the radio dropped + // it. A key recorded outside a round is discarded when the next round drains. + contactsDeletedDuringSync.insert(publicKey) logger.info("Overwrite oldest: device deleted contact with key \(pubKeyPrefix)...") diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift index 64daeaad9..ab22934f2 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift @@ -117,8 +117,7 @@ extension ChatCoordinator { /// 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 + updateRenderState { $0.appendingItem(item) } } /// Replace a single render-state item by ID via `transform`. No-op on diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift index 5d4e0617f..91f308c41 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift @@ -80,22 +80,8 @@ extension ChatCoordinator { } } - /// 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. + /// Drop all state and re-fetch the loaded window. Runs on the window + /// lane so it cannot interleave with populate or loadOlder. func hardReset(reason: String) { logger.warning("ChatCoordinator hardReset: \(reason, privacy: .public)") hardResetInFlight = true @@ -103,49 +89,65 @@ extension ChatCoordinator { 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 { + if let self, !Task.isCancelled, !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 - ) + await self?.performWindowOperation { + do { + try Task.checkCancellation() + guard let self else { return } + let anchorSortDate = self.messages.map(\.sortDate).min() + let window: (messages: [MessageDTO], hasMore: Bool) = switch id.conversation { + case let .dm(contactID): + try await dataStore.fetchMessageWindow( + contactID: contactID, + anchorSortDate: anchorSortDate, + floorLimit: Self.pageSize + ) + case let .channel(channelIndex): + try await dataStore.fetchMessageWindow( + radioID: id.radioID, + channelIndex: channelIndex, + anchorSortDate: anchorSortDate, + floorLimit: Self.pageSize + ) + } + #if DEBUG + await self.hardResetAfterFetchHook?() + #endif + try Task.checkCancellation() + let unfilteredCount = window.messages.count + self.replaceAll(self.hidingOutgoingReactions(window.messages)) + self.updateRenderState { + $0.with( + hasMoreMessages: window.hasMore, + totalFetchedCount: unfilteredCount + ) + } + self.renderStateInvalidated?() + } catch is CancellationError { + } catch { + self?.logger.error("hardReset refetch failed: \(String(describing: error))") } - 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. + /// Cancel in-flight maintenance Tasks. Called from + /// `ChatCoordinatorRegistry` before it drops this coordinator. func cancelInFlight() { buildItemsTask?.cancel() coalescedReloadTask?.cancel() hardResetTask?.cancel() } + + private func hidingOutgoingReactions(_ messages: [MessageDTO]) -> [MessageDTO] { + let isDM = switch conversationID.conversation { + case .dm: true + case .channel: false + } + return messages.filter { !$0.isHiddenOutgoingReaction(isDM: isDM) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift index e5e7b0c0a..794a07491 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift @@ -20,28 +20,27 @@ public enum ChatWriterRole: String, Sendable { /// sheet dismissal, navigation transitions — share one `ChatCoordinator`; /// the registry resolves instances by `ChatConversationID`. /// -/// Owned by `ChatCoordinatorRegistry` on `ServiceContainer`. Lives for the -/// lifetime of the `ServiceContainer` (i.e., the lifetime of a single -/// connection). Tears down on disconnect. +/// Owned by `ChatCoordinatorRegistry` on `AppState`. Lifetime is bounded +/// by LRU eviction and backup restore, not by the connection. @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. + /// Messages fetched per pagination page. `hardReset` uses this as the + /// window-refetch floor; `ChatViewModel` uses it for initial-load sizing. public static let pageSize: Int = 50 /// Read messages loaded above the first unread so the "New Messages" divider /// has a little context to sit beneath rather than pinning to the very top. public static let dividerReadContext: Int = 12 - /// Initial fetch size for opening a conversation. Guarantees every unread - /// message (plus a little read context) lands in the first page: otherwise a - /// conversation with more than `pageSize` unread would place the divider on a - /// message that only pages in later, leaving the jump-to-divider button with no - /// materialized target to scroll to. + /// Ceiling on the first-page fetch. Remaining unread pages in via `loadOlder`; + /// the divider stays on the oldest row of this window and is not recomputed. + public static let maxInitialPageSize: Int = 200 + + /// Initial fetch size: at least `pageSize`, enough unread plus read context + /// to land the divider when that fits, otherwise `maxInitialPageSize`. public static func initialPageSize(unreadCount: Int) -> Int { - max(pageSize, unreadCount + dividerReadContext) + min(max(pageSize, unreadCount + dividerReadContext), maxInitialPageSize) } public let conversationID: ChatConversationID @@ -114,11 +113,8 @@ public final class ChatCoordinator { @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. + /// In-flight coalesced-reload drain Task. Stored so `cancelInFlight` + /// can stop it. Distinct from `reloadInFlight`, which breaks the running loop. @ObservationIgnored public internal(set) var coalescedReloadTask: Task? @@ -127,6 +123,25 @@ public final class ChatCoordinator { @ObservationIgnored public internal(set) var hardResetTask: Task? + /// Completion marker for the latest window operation. `performWindowOperation` + /// chains on it so populate, loadOlder, and hardReset never interleave. + @ObservationIgnored + var windowOperationTask: Task? + + /// Runs `operation` after prior window operations finish, in the caller's + /// task so cancellation and errors propagate. `hardReset` mutates the + /// coordinator directly; populate and loadOlder still use a writer. + func performWindowOperation( + _ operation: @MainActor () async throws -> T + ) async rethrows -> T { + let prior = windowOperationTask + let (turnEnded, turn) = AsyncStream.makeStream() + windowOperationTask = Task { for await _ in turnEnded {} } + defer { turn.finish() } + await prior?.value + return try await operation() + } + /// Data store used by `applyReloadedIDs` for per-ID fetches. Bound at /// construction by the registry. `@ObservationIgnored` — never read /// from a view body. @@ -252,5 +267,14 @@ public final class ChatCoordinator { public func markLoadedForTesting() { markLoaded() } + + /// When set, populate throws this after the entry spinner clear. + public var testPopulateFetchError: Error? + + /// Awaited in populate after the window fetch so a test can cancel before commit. + public var testPopulateAfterFetchHook: (@MainActor () async -> Void)? + + /// Awaited in `hardReset` after the window fetch so a test can cancel before `replaceAll`. + public var hardResetAfterFetchHook: (@MainActor () async -> Void)? #endif } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift index 92e640d97..9c8cb796a 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift @@ -1,14 +1,14 @@ import Foundation /// Owns `ChatCoordinator` instances keyed by `ChatConversationID`. -/// Owned by `AppState`; tears down on disconnect/radio-switch and rebinds -/// its dataStore when services arrive. Multiple consumers resolving the -/// same `ChatConversationID` share one `ChatCoordinator`, so canonical -/// chat state stays unified across views. +/// Owned by `AppState` and outlives connections. Multiple consumers +/// resolving the same `ChatConversationID` share one `ChatCoordinator`, +/// so canonical chat state stays unified across views. /// /// Capped by an LRU policy (default 16 entries) so the steady-state memory /// footprint stays bounded even on long sessions across many conversations. -/// Evicted coordinators have their in-flight builds cancelled. +/// Evicted coordinators have their in-flight builds cancelled. `clear()` +/// empties entries; the registry stays and later lookups mint fresh ones. /// /// Intentionally not `@Observable` — views resolve one coordinator and /// observe that coordinator's properties. The registry is a lookup table; @@ -19,7 +19,7 @@ public final class ChatCoordinatorRegistry { private var entries: [(id: ChatConversationID, coordinator: ChatCoordinator)] = [] private let capacity: Int - private(set) var dataStore: PersistenceStore + private let dataStore: PersistenceStore public init( dataStore: PersistenceStore, @@ -58,14 +58,9 @@ public final class ChatCoordinatorRegistry { entries.first(where: { $0.id == id })?.coordinator } - 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() { + /// Cancel in-flight builds and drop all entries. The registry stays + /// usable; `coordinator(for:)` mints fresh empty entries. + public func clear() { for entry in entries { entry.coordinator.cancelInFlight() } diff --git a/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift b/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift index 58b6aa9b0..03827ec26 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift @@ -4,10 +4,11 @@ import Foundation /// /// Minted exclusively by `ChatCoordinator.bindWriter(owner:role:...)`; the /// coordinator's mutation methods are internal to MC1Services, so holding a -/// writer is the only way app code can mutate a timeline. Every forwarder -/// checks the mint generation against the coordinator's current one: once a -/// newer writer is bound, this writer's mutations no-op, so a stale prime -/// (or a superseded view model) can never write over the live conversation. +/// writer is the only way app code can mutate a timeline. Every mutation +/// forwarder checks the mint generation against the coordinator's current +/// one: once a newer writer is bound, this writer's mutations no-op, so a +/// stale prime (or a superseded view model) can never write over the live +/// conversation. The window-lane enqueue is not gated. /// /// Reads are not gated; consumers keep reading `coordinator.renderState`, /// `messages`, and `messagesByID` directly. @@ -31,6 +32,25 @@ public final class ChatTimelineWriter { generation == coordinator.writerGeneration } + #if DEBUG + /// Lets populate throw after the entry spinner clear without a production parameter. + public var testPopulateFetchError: Error? { + coordinator.testPopulateFetchError + } + + public var testPopulateAfterFetchHook: (@MainActor () async -> Void)? { + coordinator.testPopulateAfterFetchHook + } + #endif + + /// Runs `operation` on the coordinator's serialized window lane. + /// No staleness gate at enqueue: mutations inside are already generation-gated. + public func performWindowOperation( + _ operation: @MainActor () async throws -> T + ) async rethrows -> T { + try await coordinator.performWindowOperation(operation) + } + /// Runs `body` only while this writer is current. A dropped `.prime` /// write is expected teardown noise; a dropped `.interactive` write /// almost always means a missed rebind (BFU scene rebuild, role diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index ce4e9e2d6..61a212eb4 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -133,7 +133,8 @@ public actor ContactService { /// - 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) + let fetchResult = try await session.getContactsReportingTotal(since: since) + let meshContacts = fetchResult.contacts eventBroadcaster.yield(.syncProgress(received: 0, total: meshContacts.count)) @@ -152,30 +153,8 @@ public actor ContactService { eventBroadcaster.yield(.syncProgress(received: receivedCount, total: meshContacts.count)) // On full sync, remove local contacts that no longer exist on device. - // Never prune the ZephCore V-contact: it is omitted from GET_CONTACTS while - // clock-deferred or disabled, but is not a real-table orphan. if since == nil { - let localContacts = try await dataStore.fetchContacts(radioID: radioID) - let selfPublicKey = try? await dataStore.fetchDevice(radioID: radioID)?.publicKey - let orphans = localContacts.filter { contact in - guard !devicePublicKeys.contains(contact.publicKey) else { return false } - if let selfPublicKey, - VContactIdentity.isVContact(publicKey: contact.publicKey, selfPublicKey: selfPublicKey) { - return false - } - return true - } - 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.deleteContact(id: localContact.id) - await cleanupCoordinator?.handleCleanup( - contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey - ) - } + try await pruneOrphans(radioID: radioID, devicePublicKeys: devicePublicKeys, reportedTotal: fetchResult.reportedTotal) } return ContactSyncResult( @@ -188,6 +167,91 @@ public actor ContactService { } } + /// Removes local contacts that a full fetch proves are gone from the device. + /// + /// Prunes only on a complete snapshot: received count must meet the + /// `contactsStart` total. A truncated stream leaves keys missing without a + /// real device deletion, so those must not prune. A genuine shrink lowers the + /// reported total in step, so a complete-but-smaller reply still prunes. + /// Both counts include the ZephCore V-contact when firmware streams it. + /// + /// A `nil` `reportedTotal` means the reply carried no `contactsStart` header, + /// so the device total is unknown and the snapshot cannot be proven complete; + /// the prune skips. + /// + /// Without a usable self public key the V-contact cannot be identified, so the + /// prune skips rather than risk deleting a local V-contact row that firmware + /// omitted while clock-deferred. + private func pruneOrphans(radioID: UUID, devicePublicKeys: Set, reportedTotal: Int?) async throws { + guard let reportedTotal else { + logger.notice("Full sync prune skipped: device sent no contact total (missing contactsStart header)") + return + } + guard devicePublicKeys.count >= reportedTotal else { + logger.notice( + "Full sync prune skipped: received \(devicePublicKeys.count) of \(reportedTotal) reported device contacts (incomplete snapshot)" + ) + return + } + guard let selfPublicKey = try? await dataStore.fetchDevice(radioID: radioID)?.publicKey, + selfPublicKey.count == ProtocolLimits.publicKeySize else { + logger.notice("Full sync prune skipped: self public key unavailable or invalid, cannot exclude the V-contact") + return + } + + let localContacts = try await dataStore.fetchContacts(radioID: radioID) + let orphans = localContacts.filter { contact in + guard !devicePublicKeys.contains(contact.publicKey) else { return false } + if VContactIdentity.isVContact(publicKey: contact.publicKey, selfPublicKey: selfPublicKey) { + return false + } + return true + } + 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.deleteContact(id: localContact.id) + await cleanupCoordinator?.handleCleanup( + contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey + ) + } + } + + /// Full contact sync for a user-initiated refresh. + /// + /// Atomically waits out an advert-driven delta sync and claims the manual + /// refresh flag on the coordinator. Separate wait and claim hops leave a + /// window where a delta can take the claim after the wait returns and before + /// the flag is set; both publish `syncProgress` on the same event stream, so + /// that race jumps the pull-to-refresh counter. Callers still call + /// `AdvertisementService.setSyncingContacts(true)` first so no new delta + /// starts before this claim runs. + /// + /// - Parameter radioID: The device to sync from + /// - Returns: Sync result with count and timestamp + public func syncContactsForRefresh(radioID: UUID) async throws -> ContactSyncResult { + do { + try await syncCoordinator?.claimManualContactSync() + } catch is CancellationError { + throw CancellationError() + } catch { + // Wait timed out while an advert delta still held the claim. Do not + // proceed (races progress) and do not silent-skip: surface so the spinner stops. + throw ContactServiceError.syncInterrupted + } + do { + let result = try await syncContacts(radioID: radioID) + await syncCoordinator?.setManualContactSyncActive(false) + return result + } catch { + await syncCoordinator?.setManualContactSyncActive(false) + throw error + } + } + // MARK: - Get Contact /// Get a specific contact by public key from local database @@ -502,6 +566,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: resolvedNickname, isBlocked: isBlocked ?? existing.isBlocked, isMuted: existing.isMuted, @@ -523,6 +588,8 @@ public actor ContactService { } else if isBeingUnblocked { await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .unblocked, publicKey: existing.publicKey) } + + await syncCoordinator?.notifyContactsChanged() } /// Updates OCV settings for a contact @@ -553,6 +620,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: existing.nickname, isBlocked: existing.isBlocked, isMuted: existing.isMuted, @@ -579,6 +647,7 @@ public actor ContactService { } try await dataStore.saveContact(existing.with(avatarImageData: imageData)) + await syncCoordinator?.notifyContactsChanged() } // MARK: - Device Favorite Sync @@ -639,6 +708,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: existing.nickname, isBlocked: existing.isBlocked, isMuted: existing.isMuted, @@ -717,6 +787,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: existing.nickname, isBlocked: existing.isBlocked, isMuted: existing.isMuted, diff --git a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift index e2e28a885..5f53d9ae0 100644 --- a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift +++ b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift @@ -11,9 +11,6 @@ public actor 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() @@ -36,13 +33,11 @@ public actor HeardRepeatsService { eventBroadcaster.finish() } - /// Configure the service with device context. + /// Configure the service with the connected radio. /// Must be called once before processing any RX log entries. - /// Thread-safe due to actor isolation. - public func configure(radioID: UUID, localNodeName: String) { + public func configure(radioID: UUID) { self.radioID = radioID - self.localNodeName = localNodeName - logger.info("Configured with radioID: \(radioID), nodeName: \(localNodeName)") + logger.info("Configured with radioID: \(radioID)") } /// Checks if a repeat has already been recorded for this RX log entry. @@ -72,17 +67,14 @@ public actor HeardRepeatsService { 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 { + // Body after the first colon is the stored outgoing text. Sender name + // is not a join key: findSentChannelMessage already scopes to this radio. + guard let (_, 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)") diff --git a/MC1Services/Sources/MC1Services/Services/MessageDTO+ReactionVisibility.swift b/MC1Services/Sources/MC1Services/Services/MessageDTO+ReactionVisibility.swift new file mode 100644 index 000000000..380bd15bc --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/MessageDTO+ReactionVisibility.swift @@ -0,0 +1,14 @@ +import Foundation + +public extension MessageDTO { + /// A sent outgoing reaction renders as a badge, so the timeline hides + /// the row. Failed reactions stay visible so the user can retry them. + func isHiddenOutgoingReaction(isDM: Bool) -> Bool { + guard direction == .outgoing else { return false } + let isReaction = isDM + ? ReactionParser.parseDM(text) != nil + : ReactionParser.parse(text) != nil + guard isReaction else { return false } + return status != .failed + } +} diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index 083766f6f..728cc739e 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -101,7 +101,8 @@ public enum MessageFragmentBuilder { formatted: inputs.formattedText, baseColor: inputs.baseColor, isOutgoing: message.isOutgoing, - currentUserName: envInputs.currentUserName + currentUserName: envInputs.currentUserName, + translation: inputs.translation ) } @@ -198,7 +199,8 @@ public enum MessageFragmentBuilder { date: message.senderDate, hasFailed: message.hasFailed, containsSelfMention: message.containsSelfMention, - mentionSeen: message.mentionSeen + mentionSeen: message.mentionSeen, + incomingAvatar: inputs.incomingAvatar ) } @@ -208,10 +210,20 @@ public enum MessageFragmentBuilder { envInputs: EnvInputs ) -> MessageFooter { let showHop = envInputs.showIncomingHopCount && message.isFloodRouted && !message.isOutgoing - let region: String? = if envInputs.showIncomingRegion, message.isFloodRouted { - message.regionScope + // Region chip: flood routes only, when the setting is on. Coalesce dual + // fields; bake the slash join here so the scroll path does not re-filter. + let regionToShow: String? + let regionMatchNames: [String] + if envInputs.showIncomingRegion, message.isFloodRouted { + let resolved = RegionScopeSemantics.coalesce( + scope: message.regionScope, + matches: message.regionScopeMatches + ) + regionToShow = RegionScopeSemantics.chipLabel(from: resolved) + regionMatchNames = RegionScopeSemantics.matchNames(from: resolved) } else { - nil + regionToShow = nil + regionMatchNames = [] } // Send time shows inside every bubble — incoming and outgoing, DM and // channel. It is the sole time surface now that the centered cluster @@ -225,7 +237,8 @@ public enum MessageFragmentBuilder { showHop: showHop, hopCount: message.hopCount, formattedPath: inputs.formattedPath, - regionToShow: region, + regionToShow: regionToShow, + regionMatchNames: regionMatchNames, sendTimeToShow: sendTimeToShow, sendTimeWasCorrected: message.timestampCorrected, showStatusRow: message.isOutgoing, diff --git a/MC1Services/Sources/MC1Services/Services/MessageLanguageDetector.swift b/MC1Services/Sources/MC1Services/Services/MessageLanguageDetector.swift new file mode 100644 index 000000000..aeaaf6e4b --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/MessageLanguageDetector.swift @@ -0,0 +1,41 @@ +import Foundation +import NaturalLanguage + +/// Synchronous language detection. A new `NLLanguageRecognizer` per call — +/// Apple does not allow sharing one across threads. +public enum MessageLanguageDetector: Sendable { + public static let minimumLetterCount = 12 + public static let minimumConfidence = 0.85 + + /// High-confidence dominant language, or `.undetermined`. Bake/tile compare against preferred. + public static func dominantLanguage(for text: String) -> DetectedLanguage { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .undetermined } + + let letterCount = trimmed.reduce(into: 0) { count, character in + if character.isLetter { count += 1 } + } + guard letterCount >= minimumLetterCount else { return .undetermined } + + let recognizer = NLLanguageRecognizer() + // Do not set `languageHints`: a non-zero hint replaces the hypothesis at + // confidence 1, so a foreign body would never produce a Translation offer. + recognizer.processString(trimmed) + + let hypotheses = recognizer.languageHypotheses(withMaximum: 1) + guard let (language, confidence) = hypotheses.max(by: { $0.value < $1.value }) else { + return .undetermined + } + guard confidence >= minimumConfidence else { return .undetermined } + guard language != .undetermined else { return .undetermined } + return .identified(languageCode: language.rawValue) + } + + public static func isSameLanguage(_ lhs: String, _ rhs: String) -> Bool { + collapsedLanguageCode(lhs) == collapsedLanguageCode(rhs) + } + + public static func collapsedLanguageCode(_ identifier: String) -> String { + Locale.Language(identifier: identifier).languageCode?.identifier ?? identifier + } +} diff --git a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift index fc7234e6d..bc9855311 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift @@ -34,6 +34,14 @@ public enum NodeSettingsResponseParser { private static let clockResponseDateFormat = "HH:mm d/M/yyyy" + /// The UTC clock shape in firmware text, including inside an `OK - clock set:` reply. + public static func clockResponseText(in text: String) -> String? { + // 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 = text.firstMatch(of: clockResponseRegex) else { return nil } + return String(match.output.0) + } + /// 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? { @@ -48,6 +56,15 @@ public enum NodeSettingsResponseParser { return formatter.date(from: "\(match.output.1) \(match.output.2)") } + /// Seconds the node's clock is ahead of `now`. Nil when `response` has no clock shape. + public static func clockDrift( + fromClockResponse response: String, + relativeTo now: Date + ) -> TimeInterval? { + guard let nodeDate = utcDate(fromClockResponse: response) else { return nil } + return nodeDate.timeIntervalSince(now) + } + // MARK: - Clock Sync /// Outcome of a `clock sync` command response. diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift index 8924832c6..b1bd41b4a 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift @@ -119,6 +119,9 @@ extension PersistenceStore { continue } let contact = Contact(dto: dto) + // Same future-clock clamp as mergeBackupMetadata so an insert-only + // future stamp cannot pin sort/prune under max-wins. + contact.lastHeardTimestamp = clampedBackupLastHeardTimestamp(dto.lastHeardTimestamp) modelContext.insert(contact) existingContactsByKey[key] = contact contactIDsByKey[key] = contact.id diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift index d70f7928d..8c9475d9c 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift @@ -232,9 +232,31 @@ extension PersistenceStore { contact.avatarImageData = backupAvatar changed = true } + let clampedHeard = clampedBackupLastHeardTimestamp(dto.lastHeardTimestamp) + if clampedHeard > contact.lastHeardTimestamp { + contact.lastHeardTimestamp = clampedHeard + changed = true + } return changed } + /// Clamps a backup phone-clock stamp so a future exporting clock cannot pin + /// sort/prune under max-wins. Nil (legacy envelope) becomes 0. + /// Shares the same clamp as `touchContactHeard` so export-import is not lossy. + func clampedBackupLastHeardTimestamp(_ stamp: UInt32?, at now: Date = Date()) -> UInt32 { + guard let stamp else { return 0 } + return Self.clampedPhoneClockTimestamp(stamp, at: now) + } + + /// Upper-bounds a phone-clock second stamp to now + `timestampToleranceFuture`. + /// Used by live `touchContactHeard` and backup import so both agree. + static func clampedPhoneClockTimestamp(_ stamp: UInt32, at now: Date = Date()) -> UInt32 { + let nowSeconds = UInt32(now.timeIntervalSince1970) + let tolerance = UInt32(SyncCoordinator.timestampToleranceFuture) + let upperBound = nowSeconds > UInt32.max - tolerance ? UInt32.max : nowSeconds + tolerance + return min(stamp, upperBound) + } + func mergeBackupMetadata(into channel: Channel, from dto: ChannelDTO) -> Bool { var changed = false if let backupDate = dto.lastMessageDate { diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift index 09dd49bd6..b0b9b931a 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift @@ -124,6 +124,9 @@ public extension PersistenceStore { let originalAutosaveEnabled = modelContext.autosaveEnabled var didCommit = false + // Flush pending changes so a failure rollback is scoped to this import. + if modelContext.hasChanges { try modelContext.save() } + modelContext.autosaveEnabled = false defer { diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift index 593857318..6cbf3c774 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift @@ -174,7 +174,8 @@ public extension PersistenceStore { } /// Insert-only rollback: probe for messages and delete in one ModelActor - /// region with no suspension between them. + /// region with no suspension between them. Prefer an orphan contact over a + /// cascade that wipes a concurrent DM. func deleteContactIfUnreferenced(id: UUID) throws { let targetID = id let messagePredicate = #Predicate { message in @@ -188,6 +189,159 @@ public extension PersistenceStore { try deleteContact(id: id) } + /// Links direct messages stored before their contact row existed. Channel + /// rows are excluded by their non-nil channelIndex; the sender-key prefix + /// match runs in memory because `#Predicate` cannot express `Data.prefix`. + /// A prefix matching two contacts is left orphaned rather than guessed. + /// + /// Once a DM is adopted, `deleteContactIfUnreferenced` cannot roll that + /// contact back. Ghost contacts become permanent. That is the intended trade. + /// + /// Reaction-format wire text is skipped so it does not become a chat bubble. + /// Blocked contacts get no unread or mention bump. Dedup keys are recomputed + /// with the adopted contact id so a later re-delivery does not duplicate. + @discardableResult + func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] { + guard !contacts.isEmpty else { return [:] } + + let targetRadioID = radioID + let nilContactID: UUID? = nil + let nilChannel: UInt8? = nil + let incoming = MessageDirection.incoming.rawValue + // Scoped predicate with typed nils. Prefix match stays in memory because + // #Predicate cannot express Data.prefix. + let predicate = #Predicate { message in + message.radioID == targetRadioID + && message.contactID == nilContactID + && message.channelIndex == nilChannel + && message.directionRawValue == incoming + } + let orphans = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + guard !orphans.isEmpty else { return [:] } + + var contactByID: [UUID: Contact] = [:] + contactByID.reserveCapacity(contacts.count) + for candidate in contacts { + let targetID = candidate.id + let contactPredicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: contactPredicate) + descriptor.fetchLimit = 1 + if let row = try modelContext.fetch(descriptor).first { + contactByID[row.id] = row + } + } + + var adoptedCounts: [UUID: Int] = [:] + var newestDateByContact: [UUID: Date] = [:] + var unreadByContact: [UUID: Int] = [:] + var mentionByContact: [UUID: Int] = [:] + + for message in orphans { + guard let prefix = message.senderKeyPrefix, !prefix.isEmpty else { continue } + if ReactionParser.isReactionText(message.text, isDM: true) { + continue + } + + let matches = contacts.filter { candidate in + candidate.publicKey.starts(with: prefix) + } + guard matches.count == 1, let match = matches.first else { continue } + guard contactByID[match.id] != nil else { continue } + + message.contactID = match.id + message.deduplicationKey = DeduplicationKey.contentBased( + contactID: match.id, + channelIndex: nil, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + + adoptedCounts[match.id, default: 0] += 1 + let messageDate = message.sortDate + if let existing = newestDateByContact[match.id] { + newestDateByContact[match.id] = max(existing, messageDate) + } else { + newestDateByContact[match.id] = messageDate + } + if !message.isRead { + unreadByContact[match.id, default: 0] += 1 + } + if message.containsSelfMention, !message.mentionSeen { + mentionByContact[match.id, default: 0] += 1 + } + } + + guard !adoptedCounts.isEmpty else { return [:] } + + for contactID in adoptedCounts.keys { + guard let contact = contactByID[contactID] else { continue } + if let newest = newestDateByContact[contactID] { + if let existing = contact.lastMessageDate { + contact.lastMessageDate = max(existing, newest) + } else { + contact.lastMessageDate = newest + } + } + // Blocked contacts gain no unread or mention bump (live path parity). + if !contact.isBlocked { + if let unread = unreadByContact[contactID], unread > 0 { + contact.unreadCount += unread + } + if let mentions = mentionByContact[contactID], mentions > 0 { + contact.unreadMentionCount += mentions + } + } + } + + try modelContext.save() + return adoptedCounts + } + + /// Stamps phone-clock recency for a contact heard on air and the matching + /// DiscoveredNode lastHeard, creating that row from stored radio fields when missing. + /// Returns true when a Contact row existed. + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) throws -> Bool { + let targetRadioID = radioID + let targetKey = publicKey + let contactPredicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.publicKey == targetKey + } + var contactDescriptor = FetchDescriptor(predicate: contactPredicate) + contactDescriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(contactDescriptor).first else { + return false + } + + let rawStamp = UInt32(date.timeIntervalSince1970) + let stamp = Self.clampedPhoneClockTimestamp(rawStamp, at: date) + contact.lastHeardTimestamp = max(contact.lastHeardTimestamp, stamp) + + let nodePredicate = #Predicate { node in + node.radioID == targetRadioID && node.publicKey == targetKey + } + var nodeDescriptor = FetchDescriptor(predicate: nodePredicate) + nodeDescriptor.fetchLimit = 1 + if try modelContext.fetch(nodeDescriptor).first == nil { + // A known contact can lack a Discover row (paired before the row existed, + // or restored from a backup). Hearing it on air makes it discoverable. + _ = try upsertDiscoveredNode(radioID: radioID, from: contact.toContactFrame()) + } + if let node = try modelContext.fetch(nodeDescriptor).first { + node.lastHeard = date + } + + try modelContext.save() + return true + } + /// Fetch all blocked contacts for a device func fetchBlockedContacts(radioID: UUID) throws -> [ContactDTO] { let targetRadioID = radioID diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift index 992707648..fafea4e2e 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift @@ -100,6 +100,12 @@ public extension PersistenceStore { /// Update the lastContactSync timestamp for a device. /// Used to track incremental sync progress. + /// + /// The stamp is radio-RTC `max(contact.lastmod)`, not phone time. Store it + /// verbatim. Firmware filters with `contact.lastmod > since`; a phone-clock + /// upper bound can pin the watermark below every lastmod when the radio RTC + /// leads the phone, so every delta re-matches the full table and never + /// converges. func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) throws { let targetRadioID = radioID let predicate = #Predicate { device in diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index 7ab41dcd4..ac78ad8f1 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -118,6 +118,7 @@ public extension PersistenceStore { toContactName: dto.toContactName, senderTimestamp: dto.senderTimestamp.map { Int($0) }, regionScope: dto.regionScope, + regionScopeMatches: dto.regionScopeMatches, payloadTypeBits: Int(dto.payloadTypeBits) ) modelContext.insert(entry) @@ -152,8 +153,8 @@ public extension PersistenceStore { /// retention bounded to `keepCount + pruneThreshold` entries between prune passes. func pruneRxLogEntries( radioID: UUID, - keepCount: Int = 1000, - pruneThreshold: Int = 100 + keepCount: Int = RxLogRetention.keepCount, + pruneThreshold: Int = RxLogRetention.pruneThreshold ) throws { let count = try cachedRxLogEntryCount(radioID: radioID) guard count > keepCount + pruneThreshold else { return } @@ -319,48 +320,59 @@ public extension PersistenceStore { 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] { + /// Fetch transport-coded RX log entries for region reprocess. + /// Bound by prune retention; includes rows that already have a label so + /// unique, multi-match, and clear rewrites all work. + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) throws -> [RxLogEntryDTO] { let targetRadioID = radioID - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { $0.radioID == targetRadioID && - $0.transportCode != nil && - $0.regionScope == nil + $0.transportCode != nil }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] ) + descriptor.fetchLimit = limit let entries = try modelContext.fetch(descriptor) return entries.map { RxLogEntryDTO(from: $0) } } - /// Batch update `regionScope` on RX log entries by id. + /// Batch update dual region fields on RX log entries by id. func batchUpdateRxLogRegion( - updates: [(id: UUID, regionScope: String?)] + updates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] ) throws { - for update in updates { - let targetID = update.id + guard !updates.isEmpty else { return } + let byID = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) }) + let targetIDs = Array(byID.keys) + // Chunk for SQLite variable limits on large reprocess writes. + let chunkSize = 200 + for start in stride(from: 0, to: targetIDs.count, by: chunkSize) { + let chunk = Array(targetIDs[start..( - predicate: #Predicate { $0.id == targetID } + predicate: #Predicate { chunk.contains($0.id) } ) - guard let entry = try modelContext.fetch(descriptor).first else { continue } - entry.regionScope = update.regionScope + for entry in try modelContext.fetch(descriptor) { + guard let update = byID[entry.id] else { continue } + entry.regionScope = update.regionScope + entry.regionScopeMatches = update.regionScopeMatches + } } try modelContext.save() } - /// Batch update `regionScope` on incoming **channel** `Message` rows + /// Batch update dual region fields 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`. + @discardableResult func batchUpdateChannelMessageRegion( radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) throws -> [UUID] { let targetRadioID = radioID let incoming = MessageDirection.incoming.rawValue + var touchedIDs: [UUID] = [] for update in updates { let targetIndex: UInt8? = update.channelIndex let targetSenderTimestamp: UInt32? = update.senderTimestamp @@ -376,24 +388,29 @@ public extension PersistenceStore { ) for message in try modelContext.fetch(descriptor) { message.regionScope = update.regionScope + message.regionScopeMatches = update.regionScopeMatches + touchedIDs.append(message.id) } } try modelContext.save() + return touchedIDs } - /// Batch update `regionScope` on incoming **DM** `Message` rows. DMs + /// Batch update dual region fields 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`. + @discardableResult func batchUpdateDMMessageRegion( radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) throws -> [UUID] { let targetRadioID = radioID let nilChannel: UInt8? = nil let incoming = MessageDirection.incoming.rawValue + var touchedIDs: [UUID] = [] for update in updates { let targetSenderTimestamp: UInt32? = update.senderTimestamp let targetWireTimestamp: UInt32 = update.senderTimestamp @@ -410,9 +427,12 @@ public extension PersistenceStore { let candidates = try modelContext.fetch(descriptor) for message in candidates where message.senderKeyPrefix?.first == prefixByte { message.regionScope = update.regionScope + message.regionScopeMatches = update.regionScopeMatches + touchedIDs.append(message.id) } } try modelContext.save() + return touchedIDs } // MARK: - Debug Log Entries diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+FailedSends.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+FailedSends.swift new file mode 100644 index 000000000..65ecbac35 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+FailedSends.swift @@ -0,0 +1,131 @@ +import Foundation +import SwiftData + +public extension PersistenceStore { + /// Collects conversation identities that have at least one unseen outgoing + /// `.failed` send for `radioID`. Channel IDs are resolved under this radio. + /// Rooms are fetched by this radio's session IDs. + func fetchFailedSendConversationKeys(radioID: UUID) throws -> FailedSendConversationKeys { + let targetRadioID = radioID + let failedStatus = MessageStatus.failed.rawValue + let outgoing = MessageDirection.outgoing.rawValue + + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID + && message.statusRawValue == failedStatus + && message.directionRawValue == outgoing + && message.failureSeen == false + } + let failedMessages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) + + var contactIDs: Set = [] + var channelIndexes: Set = [] + contactIDs.reserveCapacity(failedMessages.count) + channelIndexes.reserveCapacity(failedMessages.count) + for message in failedMessages { + if let contactID = message.contactID { + contactIDs.insert(contactID) + } + if let channelIndex = message.channelIndex { + channelIndexes.insert(channelIndex) + } + } + + var channelIDs: Set = [] + if !channelIndexes.isEmpty { + let channels = try fetchChannels(radioID: targetRadioID) + for channel in channels where channelIndexes.contains(channel.index) { + channelIDs.insert(channel.id) + } + } + + let sessionPredicate = #Predicate { session in + session.radioID == targetRadioID + } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) + let radioSessionIDs = Set(sessions.map(\.id)) + guard !radioSessionIDs.isEmpty else { + return FailedSendConversationKeys( + contactIDs: contactIDs, + channelIDs: channelIDs, + roomSessionIDs: [] + ) + } + + let sessionIDs = Array(radioSessionIDs) + let failedRoomMessages = try fetchInChunks(keys: sessionIDs) { chunk in + let sessionChunk = chunk + let predicate = #Predicate { message in + sessionChunk.contains(message.sessionID) + && message.statusRawValue == failedStatus + && message.isFromSelf + && message.failureSeen == false + } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } + let roomSessionIDs = Set(failedRoomMessages.map(\.sessionID)) + + return FailedSendConversationKeys( + contactIDs: contactIDs, + channelIDs: channelIDs, + roomSessionIDs: roomSessionIDs + ) + } + + /// Marks current outgoing `.failed` DMs in `contactID` as seen by the user. + func markFailedSendsSeen(contactID: UUID) throws { + let targetContactID: UUID? = contactID + let failedStatus = MessageStatus.failed.rawValue + let outgoing = MessageDirection.outgoing.rawValue + let predicate = #Predicate { message in + message.contactID == targetContactID + && message.statusRawValue == failedStatus + && message.directionRawValue == outgoing + && message.failureSeen == false + } + try markMessagesFailureSeen(predicate) + } + + /// Marks current outgoing `.failed` channel rows as seen by the user. + func markFailedSendsSeen(radioID: UUID, channelIndex: UInt8) throws { + let targetRadioID = radioID + let targetIndex: UInt8? = channelIndex + let failedStatus = MessageStatus.failed.rawValue + let outgoing = MessageDirection.outgoing.rawValue + let predicate = #Predicate { message in + message.radioID == targetRadioID + && message.channelIndex == targetIndex + && message.statusRawValue == failedStatus + && message.directionRawValue == outgoing + && message.failureSeen == false + } + try markMessagesFailureSeen(predicate) + } + + /// Marks current self `.failed` room rows as seen by the user. + func markFailedSendsSeen(roomSessionID: UUID) throws { + let targetSessionID = roomSessionID + let failedStatus = MessageStatus.failed.rawValue + let predicate = #Predicate { message in + message.sessionID == targetSessionID + && message.statusRawValue == failedStatus + && message.isFromSelf + && message.failureSeen == false + } + let messages = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + guard !messages.isEmpty else { return } + for message in messages { + message.failureSeen = true + } + try modelContext.save() + } + + private func markMessagesFailureSeen(_ predicate: Predicate) throws { + let messages = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + guard !messages.isEmpty else { return } + for message in messages { + message.failureSeen = true + } + try modelContext.save() + } +} diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift index cdcb20f85..9e2a6e848 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift @@ -64,6 +64,27 @@ public extension PersistenceStore { return MessageDTO.reorderSameSenderClusters(dtos) } + func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? { + let targetContactID: UUID? = contactID + let incoming = MessageDirection.incoming.rawValue + let predicate = #Predicate { message in + message.contactID == targetContactID + && message.directionRawValue == incoming + && !message.isRead + } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { return nil } + return MessageDTO(from: message) + } + /// Fetch messages for a channel func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { let targetRadioID = radioID @@ -87,6 +108,96 @@ public extension PersistenceStore { return MessageDTO.reorderSameSenderClusters(dtos) } + /// Fetching one row beyond the limit distinguishes "exactly limit rows + /// exist" from "more remain". + private static let hasMoreProbeCount = 1 + + /// Fetch the newest window for a contact: at least `floorLimit` rows, + /// widened to every row with `sortDate` at or newer than `anchorSortDate` + /// (nil means the floor alone). `hasMore` is whether older rows remain. + /// + /// A hidden reaction below the anchor falls out and shifts + /// `totalFetchedCount` so the next `loadOlder` offset still points at it. + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + let targetContactID: UUID? = contactID + let limit = try windowLimit( + floorLimit: floorLimit, + anchorSortDate: anchorSortDate + ) { anchor in + #Predicate { message in + message.contactID == targetContactID && message.sortDate >= anchor + } + } + let predicate = #Predicate { message in + message.contactID == targetContactID + } + return try fetchMessageWindow(predicate: predicate, limit: limit) + } + + /// Fetch the newest window for a channel; see the contact variant. + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let limit = try windowLimit( + floorLimit: floorLimit, + anchorSortDate: anchorSortDate + ) { anchor in + #Predicate { message in + message.radioID == targetRadioID + && message.channelIndex == targetChannelIndex + && message.sortDate >= anchor + } + } + let predicate = #Predicate { message in + message.radioID == targetRadioID && message.channelIndex == targetChannelIndex + } + return try fetchMessageWindow(predicate: predicate, limit: limit) + } + + private func windowLimit( + floorLimit: Int, + anchorSortDate: Date?, + countPredicate: (Date) -> Predicate + ) throws -> Int { + guard let anchorSortDate else { return floorLimit } + let count = try modelContext.fetchCount( + FetchDescriptor(predicate: countPredicate(anchorSortDate)) + ) + return max(floorLimit, count) + } + + private func fetchMessageWindow( + predicate: Predicate, + limit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + Self.hasMoreProbeCount + + var fetched = try modelContext.fetch(descriptor) + let hasMore = fetched.count > limit + if hasMore { + fetched.removeLast() + } + let dtos = fetched.reversed().map { MessageDTO(from: $0) } + return (MessageDTO.reorderSameSenderClusters(dtos), hasMore) + } + /// Finds a channel message matching a parsed reaction within a timestamp window. func findChannelMessageForReaction( radioID: UUID, @@ -284,6 +395,9 @@ public extension PersistenceStore { descriptor.fetchLimit = 1 if let message = try modelContext.fetch(descriptor).first { + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.status = status try modelContext.save() } @@ -307,6 +421,9 @@ public extension PersistenceStore { guard let message = try modelContext.fetch(descriptor).first, message.status != .delivered else { return false } + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.status = status try modelContext.save() return true diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift index 58ca7c808..db31a752d 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift @@ -290,6 +290,9 @@ public extension PersistenceStore { guard let message = try modelContext.fetch(descriptor).first else { return } + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.statusRawValue = status.rawValue if let ackCode { message.ackCode = ackCode @@ -316,6 +319,9 @@ public extension PersistenceStore { guard let message = try modelContext.fetch(descriptor).first else { return } + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.statusRawValue = status.rawValue message.retryAttempt = retryAttempt message.maxRetryAttempts = maxRetryAttempts diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift index b2373f69a..07765a503 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift @@ -82,8 +82,9 @@ public actor PersistenceStore: PersistenceStoreProtocol { /// 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). + /// - v1→v2: Contact.outPathLength, DiscoveredNode.outPathLength changed Int8→UInt8. + /// SQLite INTEGER is identical; leftover Int8 flood sentinels (-1) + /// still trap UInt8 fetch (SwiftData value-preserves, not bit-cast). /// Added MessageRepeat.pathLength (UInt8, default 0). /// Added SavedTracePath.hashSize (Int, default 1). /// - v2→v3: Added PendingSend (new table; no migration impact on existing rows). @@ -96,6 +97,10 @@ public actor PersistenceStore: PersistenceStoreProtocol { /// index. /// - v5→v6: Added Contact.avatarImageData (Data?, default nil) storing a /// user-picked profile picture as a compressed JPEG blob. + /// - v6→v7: DiscoveredNode.outPathLength changed UInt8→Int so leftover -1 + /// rows fetch. DTO still exposes UInt8 via truncatingIfNeeded. + /// - v7→v8: Contact.outPathLength changed UInt8→Int for the same leftover + /// Int8 flood sentinel. DTO still exposes UInt8 via truncatingIfNeeded. public static func createContainer(inMemory: Bool = false) throws -> ModelContainer { if !inMemory { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! @@ -180,7 +185,7 @@ public actor PersistenceStore: PersistenceStoreProtocol { existingNode.lastAdvertTimestamp = frame.lastAdvertTimestamp existingNode.latitude = frame.latitude existingNode.longitude = frame.longitude - existingNode.outPathLength = frame.outPathLength + existingNode.outPathLength = Int(frame.outPathLength) existingNode.outPath = frame.outPath node = existingNode isNew = false diff --git a/MC1Services/Sources/MC1Services/Services/RemoteCLICommandRewriter.swift b/MC1Services/Sources/MC1Services/Services/RemoteCLICommandRewriter.swift new file mode 100644 index 000000000..00f1cfc13 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/RemoteCLICommandRewriter.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Rewrites remote `clock sync` to `time `. Companion firmware +/// restamps CLI packets to its own RTC, so the packet timestamp is not the phone clock. +public enum RemoteCLICommandRewriter: Sendable { + public static let clockSyncCommand = "clock sync" + public static let timeCommandPrefix = "time " + + public static func rewrite(_ command: String, now: Date = Date()) -> String { + let normalized = command.split(whereSeparator: \.isWhitespace).joined(separator: " ").lowercased() + guard normalized == clockSyncCommand else { return command } + return timeCommandPrefix + String(epochSeconds32(now)) + } + + /// Saturates pre-1970 and post-2106 dates instead of trapping `UInt32(_:)`. + private static func epochSeconds32(_ date: Date) -> UInt32 { + let seconds = date.timeIntervalSince1970 + guard seconds.isFinite, seconds > 0 else { return 0 } + guard seconds < Double(UInt32.max) else { return UInt32.max } + return UInt32(seconds) + } +} diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift index f970b468f..fa7279811 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift @@ -67,6 +67,8 @@ extension RemoteNodeService { throw RemoteNodeError.permissionDenied } + let command = RemoteCLICommandRewriter.rewrite(command) + // Log CLI command (with password redaction) await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift index e958f2889..9a076a4c8 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift @@ -235,13 +235,6 @@ public extension RemoteNodeService { eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: true)) keepAliveIntervals[remoteSession.id] = Self.defaultKeepAliveInterval - // Detached from the login path: reading the radio clock is a BLE - // round trip that must not delay the login continuation. - let sessionID = remoteSession.id - let serverTime = result.serverTime - Task { [weak self] in - await self?.recordLoginClockDrift(sessionID: sessionID, serverTime: serverTime) - } } catch { logger.error("handleLoginResult: failed to update session state: \(error)") } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 9228aa63e..dcb2e5b18 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -52,11 +52,6 @@ public actor RemoteNodeService { /// Cycling counter for CLI wire prefixes ("00|" through "FF|"). var cliPrefixCounter: UInt8 = 0 - /// Clock drift measured at the last successful login, keyed by session ID. - /// Positive means the remote node's clock is ahead of the connected radio. - /// In-memory only; drift is re-measured on every login. - var loginClockDrifts: [UUID: TimeInterval] = [:] - /// A task queued for a node's CLI slot while another command is in flight. struct CLISlotWaiter { let id: UUID @@ -69,11 +64,6 @@ public actor RemoteNodeService { /// FIFO waiters for a node's CLI slot, keyed by 6-byte public key prefix. var cliSlotWaiters: [Data: [CLISlotWaiter]] = [:] - /// Reads the connected radio's clock; wired by `ServiceContainer`. Clock - /// drift is measured against the radio, not the phone, because mesh packet - /// timestamps come from the radio's RTC. - var radioClockProvider: (@Sendable () async -> Date?)? - /// Keep-alive timer tasks var keepAliveTasks: [UUID: Task] = [:] @@ -248,29 +238,6 @@ public actor RemoteNodeService { return String(format: "%02X%@", cliPrefixCounter, String(CLIResponse.echoPrefixSeparator)) } - // MARK: - Login Clock Drift - - /// Sets the closure used to read the connected radio's clock. - public func setRadioClockProvider(_ provider: @escaping @Sendable () async -> Date?) { - radioClockProvider = provider - } - - /// Records the clock drift measured from a login response, relative to the - /// radio's clock (falling back to the phone when the radio can't be read). - func recordLoginClockDrift(sessionID: UUID, serverTime: Date?) async { - guard let serverTime else { return } - let reference = await radioClockProvider?() ?? Date() - let drift = serverTime.timeIntervalSince(reference) - loginClockDrifts[sessionID] = drift - logger.info("Login clock drift for session \(sessionID): \(Int(drift))s") - } - - /// The remote node's clock drift measured at its last login this connection. - /// Positive means the node's clock is ahead of the connected radio. - public func loginClockDrift(sessionID: UUID) -> TimeInterval? { - loginClockDrifts[sessionID] - } - func timeInterval(for duration: Duration) -> TimeInterval { let (seconds, attoseconds) = duration.components return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift index 3f4c760d7..7fae44bab 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift @@ -6,11 +6,22 @@ private let logger = PersistentLogger(subsystem: "com.mc1", category: "RxLogServ extension RxLogService { private static let missLogThrottleSeconds: TimeInterval = 60 + private static let ambiguousLogThrottleSeconds: TimeInterval = 60 /// Offset of the unencrypted sender prefix byte in a DM `packetPayload`, /// matching `findRxLogEntryBySenderPrefix`'s correlation key. private static let dmSenderPrefixByteOffset = 1 + /// Bound reprocess scan to the full retention window so rows that exist + /// between prune passes are not skipped. `transportCode` is unindexed; + /// fetch uses indexed `radioID` + `receivedAt`. + static let regionReprocessFetchLimit = + RxLogRetention.keepCount + RxLogRetention.pruneThreshold + + /// Yield every N entries so live `process` can interleave during multi-match + /// HMAC work (O(R) per entry). + private static let reprocessYieldInterval = 32 + /// Build a `[(name, scopeKey)]` array from the supplied region names. /// Skips names that `TransportCodeRegionResolver.deriveScopeKey` rejects /// (`$`-prefixed and empty/whitespace names). @@ -23,97 +34,139 @@ extension RxLogService { } } - /// 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, + /// Resolve `transport_codes[0]` into dual storage fields. + /// Live cost is O(R) HMACs per transport-coded packet; cache rebuilds only + /// when known regions change. + func resolveRegion(for parsed: ParsedRxLogData) -> (regionScope: String?, regionScopeMatches: [String]) { + resolveRegionStorage( + transportCode: parsed.transportCode, payloadTypeBits: parsed.payloadTypeBits, payload: parsed.packetPayload ) - if match == nil { - logRegionMissThrottled() - } - 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`. + /// Update known regions, rebuild the scope-key cache, and reprocess. + /// Runs even when the new list is empty so sticky labels can clear. public func updateKnownRegions(_ regions: [String]) async { guard knownRegions != regions else { return } knownRegions = regions scopeKeyCache = Self.buildScopeKeyCache(from: regions) - if !regions.isEmpty { - await reprocessNoRegionEntries() - } + await reprocessRegionEntries() + } + + /// Replace the scope-key cache and reprocess. Lets tests inject two names + /// that share one key without needing a real 16-bit code collision. + func replaceScopeKeyCacheAndReprocess( + _ cache: [(name: String, key: Data)] + ) async { + knownRegions = cache.map(\.name) + scopeKeyCache = cache + await reprocessRegionEntries() } - /// 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)`). + /// Re-resolve retained transport-coded entries against the current cache and + /// write dual region fields on `RxLogEntry` and correlated `Message` rows. /// - /// 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 { + /// Concurrent callers set dirty and park on a continuation the owner resumes + /// when the drain finishes. Overlapping callers converge on the final cache; + /// the number of passes is bounded by the number of overlapping callers that + /// arrive across pass boundaries. + func reprocessRegionEntries() async { + guard !Task.isCancelled else { return } + + regionReprocessDirty = true if isReprocessingRegions { - regionReprocessDirty = true + await withCheckedContinuation { (continuation: CheckedContinuation) in + regionReprocessWaiters.append(continuation) + } + guard !Task.isCancelled else { return } + // Owner finished between our dirty set and its last while-check. + // Re-enter so the mark is not lost. + if regionReprocessDirty { + await reprocessRegionEntries() + } return } + isReprocessingRegions = true - defer { isReprocessingRegions = false } + defer { + isReprocessingRegions = false + let waiters = regionReprocessWaiters + regionReprocessWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } - guard let radioID else { return } + guard let radioID else { + regionReprocessDirty = false + return + } - repeat { + while regionReprocessDirty { + guard !Task.isCancelled else { return } regionReprocessDirty = false await runReprocessPass(radioID: radioID) - } while regionReprocessDirty + } } private func runReprocessPass(radioID: UUID) async { do { - let entries = try await dataStore.fetchEntriesWithMissingRegion( - radioID: radioID + let entries = try await dataStore.fetchEntriesWithTransportCode( + radioID: radioID, + limit: Self.regionReprocessFetchLimit ) guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) entries for region back-fill") + logger.info("Re-processing \(entries.count) transport-coded entries for region resolution") + + // Snapshot so a mid-pass known-regions change is applied on the dirty re-run. + let cacheSnapshot = scopeKeyCache - var rxUpdates: [(id: UUID, regionScope: String?)] = [] - var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + var rxUpdates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] = [] + var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] + var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] - for entry in entries { + for (index, entry) in entries.enumerated() { guard !Task.isCancelled else { break } - guard let resolved = resolveRegionScope(for: entry) else { continue } - rxUpdates.append((id: entry.id, regionScope: resolved)) + if index > 0, index.isMultiple(of: Self.reprocessYieldInterval) { + await Task.yield() + } + + let resolved = resolveRegionStorage( + transportCode: entry.transportCode, + payloadTypeBits: entry.payloadTypeBits, + payload: entry.packetPayload, + cache: cacheSnapshot, + logMisses: false + ) + + let scopeChanged = entry.regionScope != resolved.regionScope + let matchesChanged = entry.regionScopeMatches != resolved.regionScopeMatches + guard scopeChanged || matchesChanged else { continue } + + rxUpdates.append(( + id: entry.id, + regionScope: resolved.regionScope, + regionScopeMatches: resolved.regionScopeMatches + )) guard let senderTimestamp = entry.senderTimestamp else { continue } if let channelIndex = entry.channelIndex { channelMessageUpdates.append(( channelIndex: channelIndex, senderTimestamp: senderTimestamp, - regionScope: resolved + regionScope: resolved.regionScope, + regionScopeMatches: resolved.regionScopeMatches )) } else if entry.packetPayload.count >= Self.dmSenderPrefixByteOffset + 1 { let prefixByte = entry.packetPayload[Self.dmSenderPrefixByteOffset] dmMessageUpdates.append(( senderPrefixByte: prefixByte, senderTimestamp: senderTimestamp, - regionScope: resolved + regionScope: resolved.regionScope, + regionScopeMatches: resolved.regionScopeMatches )) } } @@ -121,16 +174,32 @@ extension RxLogService { if !rxUpdates.isEmpty { try await dataStore.batchUpdateRxLogRegion(updates: rxUpdates) } + + var touchedMessageIDs = Set() if !channelMessageUpdates.isEmpty { - try await dataStore.batchUpdateChannelMessageRegion(radioID: radioID, updates: channelMessageUpdates) + let ids = try await dataStore.batchUpdateChannelMessageRegion( + radioID: radioID, + updates: channelMessageUpdates + ) + touchedMessageIDs.formUnion(ids) } if !dmMessageUpdates.isEmpty { - try await dataStore.batchUpdateDMMessageRegion(radioID: radioID, updates: dmMessageUpdates) + let ids = try await dataStore.batchUpdateDMMessageRegion( + radioID: radioID, + updates: dmMessageUpdates + ) + touchedMessageIDs.formUnion(ids) + } + + if !touchedMessageIDs.isEmpty { + regionUpdateBroadcaster.yield(Array(touchedMessageIDs)) } let messageCount = channelMessageUpdates.count + dmMessageUpdates.count if !rxUpdates.isEmpty || messageCount > 0 { - logger.info("Back-filled \(rxUpdates.count) RxLog entries, \(messageCount) messages") + logger.info( + "Region reprocess wrote \(rxUpdates.count) RxLog entries, \(messageCount) message correlations (\(touchedMessageIDs.count) message IDs)" + ) } } catch { logger.error("Failed to re-process region entries: \(error.localizedDescription)") @@ -150,26 +219,50 @@ extension RxLogService { } } - /// 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 logRegionAmbiguousThrottled(names: [String]) { + let now = Date() + if let last = lastRegionAmbiguousLogTime, now.timeIntervalSince(last) < Self.ambiguousLogThrottleSeconds { + return + } + lastRegionAmbiguousLogTime = now + // Public region names only; never scopeKey bytes, payload, or raw codes. + logger.debug( + "Region resolution ambiguous: \(names.count) matches \(names.joined(separator: ", "))" ) } - 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 } + private func resolveRegionStorage( + transportCode: Data?, + payloadTypeBits: UInt8, + payload: Data, + cache: [(name: String, key: Data)]? = nil, + logMisses: Bool = true + ) -> (regionScope: String?, regionScopeMatches: [String]) { + guard let transportCode, transportCode.count >= 2 else { + return (nil, []) + } + let activeCache = cache ?? scopeKeyCache + // Empty cache skips HMACs but still projects none so writers can clear labels. + guard !activeCache.isEmpty else { + return (nil, []) + } let code0 = transportCode.readUInt16LE(at: 0) - return TransportCodeRegionResolver.findMatchingRegion( - scopeKeys: scopeKeyCache, + let match = TransportCodeRegionResolver.matchRegions( + scopeKeys: activeCache, expectedTransportCode0: code0, payloadTypeBits: payloadTypeBits, payload: payload ) + if logMisses { + switch match { + case .none: + logRegionMissThrottled() + case let .ambiguous(names): + logRegionAmbiguousThrottled(names: names) + case .unique: + break + } + } + return RegionScopeSemantics.storageFields(from: match) } } diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService.swift b/MC1Services/Sources/MC1Services/Services/RxLogService.swift index 8effd76b3..e84abb24c 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService.swift @@ -23,9 +23,18 @@ public actor RxLogService { /// via `entryStream()`; finished by `ServiceContainer.tearDown()`. private nonisolated let entryBroadcaster = EventBroadcaster() + /// Multicast broadcaster for Message IDs whose region fields reprocess + /// rewrote. Open chats subscribe via `regionUpdateEvents()` to re-bake + /// region footers. + nonisolated let regionUpdateBroadcaster = EventBroadcaster<[UUID]>() + /// Event monitoring private var eventMonitorTask: Task? + /// Connect-path region reprocess. Cancelled with event monitoring so it + /// cannot write or yield after teardown finishes the broadcaster. + private var regionReprocessTask: Task? + /// Heard repeats processing. /// Injected by `ServiceContainer` at construction. private let heardRepeatsService: HeardRepeatsService? @@ -35,14 +44,22 @@ public actor RxLogService { private var isReprocessingDMs = false var isReprocessingRegions = false + /// Waiters parked while a region reprocess drain owns the actor. + /// The owner resumes them in its defer. + var regionReprocessWaiters: [CheckedContinuation] = [] + // Region resolution state var knownRegions: [String] = [] var scopeKeyCache: [(name: String, key: Data)] = [] var lastRegionMissLogTime: Date? + var lastRegionAmbiguousLogTime: 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. + /// Overlapping callers converge on the final cache; the number of passes + /// is bounded by the number of overlapping callers that arrive across + /// pass boundaries. var regionReprocessDirty = false public init(session: any MeshCoreSessionProtocol, dataStore: any PersistenceStoreProtocol, heardRepeatsService: HeardRepeatsService?) { @@ -58,6 +75,7 @@ public actor RxLogService { deinit { eventMonitorTask?.cancel() + regionReprocessTask?.cancel() } // MARK: - Event Monitoring @@ -66,16 +84,22 @@ public actor RxLogService { public func startEventMonitoring(radioID: UUID) { self.radioID = radioID eventMonitorTask?.cancel() + regionReprocessTask?.cancel() + regionReprocessTask = nil 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 + // Build known-regions cache (and channel/contact secrets). await loadSecretsFromDatabase(radioID: radioID) + // Subscribe before reprocess. EventDispatcher only buffers for existing + // subscribers; awaiting full reprocess here would drop live RX. let events = await session.events(filter: .rxLogData) + // Sibling reprocess so the for-await loop drains while store work runs. + await self.launchRegionReprocessTask() + for await event in events { guard !Task.isCancelled else { break } if case let .rxLogData(parsed) = event { @@ -85,6 +109,12 @@ public actor RxLogService { } } + /// Store a cancellable handle for connect-path region reprocess. + private func launchRegionReprocessTask() { + regionReprocessTask?.cancel() + regionReprocessTask = Task { await self.reprocessRegionEntries() } + } + /// Load channel secrets and contact public keys from database to enable decryption before sync completes. private func loadSecretsFromDatabase(radioID: UUID) async { do { @@ -122,6 +152,8 @@ public actor RxLogService { public func stopEventMonitoring() { eventMonitorTask?.cancel() eventMonitorTask = nil + regionReprocessTask?.cancel() + regionReprocessTask = nil } /// Returns a fresh multicast stream of newly persisted entries. @@ -131,11 +163,18 @@ public actor RxLogService { entryBroadcaster.subscribe() } - /// Ends every `entryStream()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release their service - /// references. + /// Multicast stream of Message IDs whose region fields reprocess changed. + /// Open chats reload baked region footers from these IDs. + public nonisolated func regionUpdateEvents() -> AsyncStream<[UUID]> { + regionUpdateBroadcaster.subscribe() + } + + /// Ends every `entryStream()` / `regionUpdateEvents()` subscriber's + /// for-await loop. Called by `ServiceContainer.tearDown()` so consumer + /// tasks release their service references. nonisolated func finishEntryStream() { entryBroadcaster.finish() + regionUpdateBroadcaster.finish() } /// Rebuilds the channel cache from a fresh channel list. @@ -418,7 +457,7 @@ public actor RxLogService { } } - let regionScope = resolveRegionScope(for: parsed) + let regionFields = resolveRegion(for: parsed) // Create DTO let dto = RxLogEntryDTO( @@ -429,7 +468,8 @@ public actor RxLogService { decryptStatus: decryptStatus, fromContactName: fromContactName, senderTimestamp: senderTimestamp, - regionScope: regionScope, + regionScope: regionFields.regionScope, + regionScopeMatches: regionFields.regionScopeMatches, decodedText: decodedText ) diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift index 402dfeb0d..3260d6e58 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift @@ -1,9 +1,8 @@ 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. + /// Mock channel rows for a slot index. Incoming messages use `channelIndex` and + /// `senderNodeName`; `senderKeyPrefix` stays nil, matching the MeshCore channel payload. public static func channelMessages(for index: UInt8) -> [MessageDTO] { let now = Date() switch index { @@ -24,9 +23,9 @@ extension MockDataProvider { private static func meshHQChannelMessages(now: Date) -> [MessageDTO] { let path = encodePathLen(hashSize: 1, hopCount: 2) - let senders: [(name: String, seed: UInt8)] = [ - ("Alice Chen", 10), ("Bob Martinez", 20), ("Carol Diaz", 90), - ("Frank Wilson", 60), ("Hannah Lee", 80) + let senders = [ + "Alice Chen", "Bob Martinez", "Carol Diaz", + "Frank Wilson", "Hannah Lee" ] let lines = [ "Morning net check — who's on frequency?", @@ -71,8 +70,7 @@ extension MockDataProvider { channelIndex: meshHQChannelIndex, pathLength: path, snr: 6.5 + Double(i % 5) * 0.4, - senderKeyPrefix: mockPublicKey(seed: sender.seed).prefix(6), - senderNodeName: sender.name, + senderNodeName: sender, isRead: isRead ) } @@ -91,20 +89,20 @@ extension MockDataProvider { "C0000000-0000-0000-0000-000000000001", now.addingTimeInterval(-9000), "Anyone monitoring the north repeater today?", - sender: "Alice Chen", keySeed: 10, snr: 7.4, path: path + sender: "Alice Chen", 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 + sender: "Alice Chen", 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 + sender: "Bob Martinez", snr: 6.9, path: path ), MockMessageFactory.message( id: UUID(uuidString: "C0000000-0000-0000-0000-000000000004")!, @@ -115,6 +113,21 @@ extension MockDataProvider { channelIndex: publicChannelIndex, ackCode: 50001, pathLength: path + ), + // RX-log correlated flood: hop IDs plus a multi-match region. + MockMessageFactory.message( + id: publicAmbiguousRegionMessageID, + createdAt: now.addingTimeInterval(-600), + text: "Flood from a region that matches two names on this radio", + direction: .incoming, + channelIndex: publicChannelIndex, + pathLength: path, + snr: 6.2, + pathNodes: Data([0x10, 0x20]), + senderNodeName: "Alice Chen", + routeType: .tcFlood, + regionScope: nil, + regionScopeMatches: ambiguousRegionNames ) ] } @@ -127,21 +140,21 @@ extension MockDataProvider { "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 + sender: "Carol Diaz", 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 + sender: "Carol Diaz", 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, + sender: "Carol Diaz", snr: 8.8, path: path, isRead: false, containsSelfMention: true, mentionSeen: false ) ] @@ -155,7 +168,7 @@ extension MockDataProvider { "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 + sender: "Frank Wilson", snr: 5.2, path: path ), MockMessageFactory.message( id: UUID(uuidString: "C2000000-0000-0000-0000-000000000002")!, @@ -176,7 +189,6 @@ extension MockDataProvider { _ createdAt: Date, _ text: String, sender: String, - keySeed: UInt8, snr: Double, path: UInt8, isRead: Bool = true, @@ -194,7 +206,6 @@ extension MockDataProvider { channelIndex: index, pathLength: path, snr: snr, - senderKeyPrefix: mockPublicKey(seed: keySeed).prefix(6), senderNodeName: sender, isRead: isRead, containsSelfMention: containsSelfMention, diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift index 30d4b9d13..7374adf43 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift @@ -14,7 +14,7 @@ extension MockDataProvider { name: "Public", secret: channelSecret(seed: 0xA0), isEnabled: true, - lastMessageDate: now.addingTimeInterval(-1200), + lastMessageDate: now.addingTimeInterval(-600), unreadCount: 2, notificationLevel: .all, isFavorite: false diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift index 94c151032..c701028be 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift @@ -20,6 +20,7 @@ public extension MockDataProvider { latitude: 37.7849, longitude: -122.4094, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -42,6 +43,7 @@ public extension MockDataProvider { latitude: 37.7649, longitude: -122.4294, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -64,6 +66,7 @@ public extension MockDataProvider { latitude: 37.7549, longitude: -122.4394, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -86,6 +89,7 @@ public extension MockDataProvider { latitude: 37.7449, longitude: -122.4494, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -108,6 +112,7 @@ public extension MockDataProvider { latitude: 37.7349, longitude: -122.4594, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: true, isMuted: false, @@ -130,11 +135,12 @@ public extension MockDataProvider { latitude: 37.7249, longitude: -122.4694, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: "Dad", isBlocked: false, isMuted: false, isFavorite: false, - lastMessageDate: now.addingTimeInterval(-7200), // 2 hours ago + lastMessageDate: now.addingTimeInterval(-1800), unreadCount: 0 ), @@ -152,6 +158,7 @@ public extension MockDataProvider { latitude: 0, longitude: 0, lastModified: UInt32(now.timeIntervalSince1970) - 86400, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -174,6 +181,7 @@ public extension MockDataProvider { latitude: 37.7149, longitude: -122.4794, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift index 0e08a9bbb..46a95307f 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift @@ -218,8 +218,8 @@ extension MockDataProvider { ] } - /// 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). + /// Frank "Dad" (2-hop): weak/very-weak SNR, unique and ambiguous flood-region + /// incoming, and a heard-repeat-backed outgoing (repeats seeded separately). private static func frankMessages(now: Date) -> [MessageDTO] { let key = mockPublicKey(seed: 60).prefix(6) return [ @@ -258,9 +258,9 @@ extension MockDataProvider { pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), senderKeyPrefix: key ), - // Flood-routed incoming carrying a region scope. + // Flood-routed incoming carrying a unique region. MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000004")!, + id: frankFloodUniqueMessageID, createdAt: now.addingTimeInterval(-3600), text: "Storm warning for the ridge tonight ⛈️", direction: .incoming, @@ -270,7 +270,23 @@ extension MockDataProvider { pathNodes: Data([0x10, 0x44, 0x60]), senderKeyPrefix: key, routeType: .tcFlood, - regionScope: "US915" + regionScope: uniqueRegionName, + regionScopeMatches: [uniqueRegionName] + ), + // Flood-routed incoming whose transport code matches two known regions. + MockMessageFactory.message( + id: frankFloodAmbiguousMessageID, + createdAt: now.addingTimeInterval(-1800), + text: "Same storm, heard under two regions", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 1, hopCount: 3), + snr: 3.0, + pathNodes: Data([0x10, 0x44, 0x60]), + senderKeyPrefix: key, + routeType: .tcFlood, + regionScope: nil, + regionScopeMatches: ambiguousRegionNames ) ] } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+RxLog.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+RxLog.swift new file mode 100644 index 000000000..d8e633b14 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+RxLog.swift @@ -0,0 +1,59 @@ +import Foundation + +extension MockDataProvider { + /// Unique and ambiguous flood-region rows. `decodedText` is omitted because + /// it is `@Transient` on `RxLogEntry` and `saveRxLogEntry` does not persist it. + static var rxLogEntries: [RxLogEntryDTO] { + [ + rxLogEntry( + id: uniqueRxLogEntryID, + receivedAtOffset: -3600, + packetPayload: Data([0x01, 0xAA, 0xBB]), + regionScope: uniqueRegionName, + regionScopeMatches: [uniqueRegionName] + ), + rxLogEntry( + id: ambiguousRxLogEntryID, + receivedAtOffset: -1800, + packetPayload: Data([0x02, 0xCC, 0xDD]), + regionScope: nil, + regionScopeMatches: ambiguousRegionNames + ) + ] + } + + private static let seedTransportCode = Data([0x11, 0x22, 0x33, 0x44]) + + private static func rxLogEntry( + id: UUID, + receivedAtOffset: TimeInterval, + packetPayload: Data, + regionScope: String?, + regionScopeMatches: [String] + ) -> RxLogEntryDTO { + let parsed = ParsedRxLogData( + snr: 8.0, + rssi: -70, + rawPayload: Data([0x15, 0x01, 0x02, 0x03]), + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: seedTransportCode, + pathLength: 1, + pathNodes: [0x42], + packetPayload: packetPayload + ) + return RxLogEntryDTO( + id: id, + radioID: simulatorDeviceID, + receivedAt: Date().addingTimeInterval(receivedAtOffset), + from: parsed, + channelIndex: publicChannelIndex, + channelName: "Public", + decryptStatus: .success, + regionScope: regionScope, + regionScopeMatches: regionScopeMatches + ) + } +} diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift index e4bcf0aab..0bf0e34e2 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift @@ -37,8 +37,18 @@ public enum MockDataProvider { 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 frankFloodUniqueMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000004")! + static let frankFloodAmbiguousMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000005")! static let bayAreaReactedMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000002")! static let bayAreaMentionMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000003")! + static let publicAmbiguousRegionMessageID = UUID(uuidString: "C0000000-0000-0000-0000-000000000005")! + static let uniqueRxLogEntryID = UUID(uuidString: "A0000000-0000-0000-0000-000000000001")! + static let ambiguousRxLogEntryID = UUID(uuidString: "A0000000-0000-0000-0000-000000000002")! + + /// Seed values matching `RegionScopeSemantics.storageFields`: unique is + /// `(name, [name])`, ambiguous is `(nil, sorted names)`. + static let uniqueRegionName = "US915" + static let ambiguousRegionNames = ["de-by", "de-hh"] // MARK: - Mock Public Keys diff --git a/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift b/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift index 96726ee07..103b925d9 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift @@ -30,7 +30,8 @@ enum MockMessageFactory { timestampCorrected: Bool = false, senderTimestamp: UInt32? = nil, routeType: RouteType? = nil, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) -> MessageDTO { MessageDTO( id: id, @@ -60,7 +61,8 @@ enum MockMessageFactory { timestampCorrected: timestampCorrected, senderTimestamp: senderTimestamp, routeType: routeType, - regionScope: regionScope + regionScope: regionScope, + regionScopeMatches: regionScopeMatches ) } } diff --git a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift index a4d5c1940..14fdf1834 100644 --- a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift +++ b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift @@ -39,8 +39,13 @@ final class SimulatorConnectionMode { func seedDataStore(_ dataStore: PersistenceStore) async throws { try await dataStore.saveDevice(MockDataProvider.simulatorDevice) + // Mock contacts omit avatarImageData; copy any existing value onto the upsert so saveContact(_:) does not apply nil. for contact in MockDataProvider.contacts { - try await dataStore.saveContact(contact) + if let existingAvatar = try await dataStore.fetchContact(id: contact.id)?.avatarImageData { + try await dataStore.saveContact(contact.with(avatarImageData: existingAvatar)) + } else { + try await dataStore.saveContact(contact) + } } for channel in MockDataProvider.channels { @@ -88,6 +93,7 @@ final class SimulatorConnectionMode { } } + try await seedRxLogEntries(dataStore) try await seedNodeStatusSnapshots(dataStore) logger.info( @@ -96,6 +102,19 @@ final class SimulatorConnectionMode { ) } + /// Inserts the two flood-region RX-log fixtures when they are missing. + /// `saveRxLogEntry` is insert-only, so a second connect must skip existing ids. + private func seedRxLogEntries(_ dataStore: PersistenceStore) async throws { + let existingIDs = try await Set( + dataStore.fetchRxLogEntries( + radioID: MockDataProvider.simulatorDeviceID + ).map(\.id) + ) + for entry in MockDataProvider.rxLogEntries where !existingIDs.contains(entry.id) { + try await dataStore.saveRxLogEntry(entry) + } + } + /// Seeds a node's GPS track once so the location History list and map have /// content to render. Skipped when the node already has a snapshot, so the /// now-relative timestamps aren't restacked into a duplicate track on every diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift index a788755d6..849e31e1d 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift @@ -10,6 +10,7 @@ extension SyncCoordinator { let packetHash: String? let routeType: RouteType? let regionScope: String? + let regionScopeMatches: [String] } /// Looks up path data from an RxLogEntry to correlate with an incoming message. @@ -38,7 +39,14 @@ extension SyncCoordinator { } 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) + return RxLogLookupResult( + pathNodes: pathNodes, + pathLength: pathLength, + packetHash: rxEntry.packetHash, + routeType: rxEntry.routeType, + regionScope: rxEntry.regionScope, + regionScopeMatches: rxEntry.regionScopeMatches + ) } // Fallback for DMs: if timestamp-based lookup failed (e.g., RxLog decryption @@ -53,7 +61,14 @@ extension SyncCoordinator { 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: rxEntry.pathNodes, + pathLength: rxEntry.pathLength, + packetHash: rxEntry.packetHash, + routeType: rxEntry.routeType, + regionScope: rxEntry.regionScope, + regionScopeMatches: rxEntry.regionScopeMatches + ) } logger.debug("No RxLogEntry found for direct message (primary + fallback), senderTimestamp: \(senderTimestamp)") } else if let channelIndex { @@ -69,7 +84,14 @@ extension SyncCoordinator { } } - return RxLogLookupResult(pathNodes: nil, pathLength: defaultPathLength, packetHash: nil, routeType: nil, regionScope: nil) + return RxLogLookupResult( + pathNodes: nil, + pathLength: defaultPathLength, + packetHash: nil, + routeType: nil, + regionScope: nil, + regionScopeMatches: [] + ) } /// Increments unread counts and posts a notification for a direct message. diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift index 89b486fe4..e8adec0a1 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift @@ -72,6 +72,28 @@ extension SyncCoordinator { } } + /// When a DM arrives with no local contact, try to create the row from a + /// pending 0x80 key before save/notify. Adoption still links pure orphans + /// without posting a second alert. + private func resolveDirectContactIfNeeded( + _ kind: IncomingMessageKind, + dependencies: SyncDependencies, + radioID: UUID + ) async -> IncomingMessageKind { + guard case let .direct(message, contact) = kind, contact == nil else { + return kind + } + let prefix = message.senderPublicKeyPrefix + guard !prefix.isEmpty else { return kind } + + guard let materialized = await dependencies.advertisementService + .materializeContactForPendingAdvert(matchingPrefix: prefix, radioID: radioID) + else { + return kind + } + return .direct(message, contact: materialized) + } + /// 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. @@ -82,6 +104,13 @@ extension SyncCoordinator { radioID: UUID, selfNodeName: String ) async { + // A DM can land in the advert delta-sync debounce window before the local + // Contact row exists. Materialize from a pending 0x80 key so unread, + // notification, and Chats-list updates use the live path exactly once. + let resolvedKind = await resolveDirectContactIfNeeded( + kind, dependencies: dependencies, radioID: radioID + ) + // Per-kind wire fields. Channel messages embed the sender as a // "NodeName: text" prefix; direct messages carry the sender key prefix. let text: String @@ -93,7 +122,7 @@ extension SyncCoordinator { let contactID: UUID? let channelIndex: UInt8? let senderKeyPrefix: Data? - switch kind { + switch resolvedKind { 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 @@ -125,7 +154,7 @@ extension SyncCoordinator { 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)") + logger.debug("Corrected invalid \(resolvedKind.logLabel) message timestamp from \(Date(timeIntervalSince1970: TimeInterval(timestamp))) to \(receiveTime)") } let sortDate = Self.sortDate(for: context, receiveTime: receiveTime) @@ -151,7 +180,7 @@ extension SyncCoordinator { // Check for self-mention before creating DTO // For channel messages, filter out messages where the user mentions themselves - let hasSelfMention: Bool = switch kind { + let hasSelfMention: Bool = switch resolvedKind { case .direct: !selfNodeName.isEmpty && MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) @@ -169,7 +198,7 @@ extension SyncCoordinator { if let parsed = TextType(rawValue: textTypeRaw) { resolvedTextType = parsed } else { - logger.warning("Unknown \(kind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") + logger.warning("Unknown \(resolvedKind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") resolvedTextType = .plain } @@ -205,20 +234,36 @@ extension SyncCoordinator { timestampCorrected: timestampCorrected, senderTimestamp: timestampCorrected ? timestamp : nil, routeType: rxResult.routeType, - regionScope: rxResult.regionScope + regionScope: rxResult.regionScope, + regionScopeMatches: rxResult.regionScopeMatches ) // Check for duplicate before saving do { if try await dependencies.dataStore.isDuplicateMessage(deduplicationKey: deduplicationKey, radioID: radioID) { - logger.info("Skipping duplicate \(kind.logLabel) message") + logger.info("Skipping duplicate \(resolvedKind.logLabel) message") return } } catch { logger.warning("Dedup check failed, proceeding with save: \(error)") } - switch kind { + // Stamp before the reaction early return so bodies and reactions both count. + if case let .direct(_, contact) = resolvedKind, let contact { + do { + _ = try await dependencies.dataStore.touchContactHeard( + radioID: radioID, + publicKey: contact.publicKey, + at: Date() + ) + } catch { + logger.error( + "lastHeard stamp failed for inbound DM: \(error.localizedDescription)" + ) + } + } + + switch resolvedKind { case let .direct(_, contact): // Check if this is a DM reaction if let contact, @@ -253,7 +298,7 @@ extension SyncCoordinator { do { try await dependencies.dataStore.saveMessage(messageDTO) - switch kind { + switch resolvedKind { case let .direct(_, contact): try await indexAndNotifyDirectMessage( messageDTO: messageDTO, @@ -282,11 +327,11 @@ extension SyncCoordinator { await notifyConversationsChanged() // Broadcast for real-time chat updates - if case let .direct(_, contact) = kind, let contact { + if case let .direct(_, contact) = resolvedKind, let contact { dataEventBroadcaster.yield(.directMessageReceived(message: messageDTO, contact: contact)) } } catch { - switch kind { + switch resolvedKind { case .direct: logger.error("Failed to save contact message: \(error)") case .channel: @@ -528,8 +573,37 @@ extension SyncCoordinator { contactType: contactType ) await notifyContactsChanged() - case .contactUpdated, .nodeStorageFullChanged, .contactDeletedCleanup, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + case let .orphanDirectMessagesAdopted(contactIDs): + // These DMs arrived before their sender's contact existed, so no + // banner or badge fired at receipt. Adoption bumped the unread counts; + // post the banner and refresh the badge now, once per contact. The + // final badge refresh also covers a muted contact, whose unread rose + // but whose suppressed banner never updates the badge itself. + for contactID in contactIDs { + do { + guard let contact = try await dependencies.dataStore.fetchContact(id: contactID), + !contact.isBlocked, + let message = try await dependencies.dataStore.newestUnreadIncomingMessage( + contactID: contactID + ) + else { continue } + await dependencies.notificationService.postDirectMessageNotification( + from: contact.displayName, + contactID: contactID, + messageText: message.text, + messageID: message.id, + isMuted: contact.isMuted + ) + } catch { + logger.error( + "Adopted DM notification failed for \(contactID): \(error.localizedDescription)" + ) + } + } + await dependencies.notificationService.updateBadgeCount() + case .contactUpdated, .conversationsChanged, .nodeStorageFullChanged, + .contactDeletedCleanup, .pathDiscoveryResponse, .traceResponse, + .traceSnrObserved: break } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift index bf0d06da0..a46d26565 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift @@ -10,6 +10,78 @@ extension SyncCoordinator { let channelRetryIndices: [UInt8] } + /// Contact watermark value meaning no contact sync has ever succeeded. + static let noContactWatermark: UInt32 = 0 + + /// `since` value for a prune-free full contact fetch: the device reports every + /// contact while local rows the device omits are kept. + private static let pruneFreeFullFetchSince = Date(timeIntervalSince1970: 0) + + /// Seconds the incremental `since` filter is rewound from the stored watermark. + /// The device reports a contact only when `lastmod > since`, so a contact modified + /// in the same second as the watermark would never be reported. One second of + /// overlap re-reports that second; the upsert behind it is idempotent. + private static let incrementalSinceOverlap: TimeInterval = 1 + + /// Max lead over the plausibility reference before a stored watermark is + /// treated as unusable for incremental `since`. Multi-day residual lastmod + /// from a radio RTC far ahead of the phone is broken; minute-scale lead is + /// normal drift. + static let contactWatermarkPlausibilitySkew: TimeInterval = 2 * 24 * 60 * 60 + + /// Bound for waiting out an advert-owned contact-sync claim. + /// Above `SessionConfiguration.contactStreamHardTimeout` (180s) so a large + /// contact table can finish streaming, but a wedged wait cannot hang forever. + static let advertContactSyncWaitTimeout: Duration = .seconds(200) + + /// Developer-facing reason when the advert claim wait hits its bound. + static let advertContactSyncWaitTimedOutMessage = + "Timed out waiting for background contact sync" + + /// How to use a stored contact-sync watermark for one fetch round. + /// Does not rewrite storage — only decides the `since` filter for this round. + enum ContactWatermarkUse: Equatable, Sendable { + /// No successful contact sync stamp yet. + case none + /// Stamp is usable for incremental `since = watermark - 1`. + case incremental(UInt32) + /// Stamp is implausibly ahead of the reference. Fetch with `since == nil` + /// this round; leave the stored value alone until write-back of the new max. + case invalid(stored: UInt32) + } + + /// The `since` filter for an incremental contact fetch from a stored watermark. + private static func incrementalSince(watermark: UInt32) -> Date { + Date(timeIntervalSince1970: Double(watermark) - incrementalSinceOverlap) + } + + /// Decides whether a stored contact-sync watermark can drive incremental sync. + /// + /// Reference clock: **phone `Date`**. `syncDeviceTimeIfNeeded` holds the radio + /// within `deviceClockDriftTolerance` (5 s) of the phone on connect and sync + /// retry, so the phone is a local proxy for radio time without an extra + /// `getTime` BLE round on every contact sync and advert delta. + /// + /// When the stamp leads the reference by more than + /// `contactWatermarkPlausibilitySkew`, return `.invalid` so the caller fetches + /// with `since == nil` once. Never rewrite the stored stamp downward here — + /// phone-clock clamps pin below radio lastmods and loop full-table deltas. + nonisolated static func contactWatermarkUse( + fromLastContactSync raw: UInt32?, + referenceNow: Date = Date() + ) -> ContactWatermarkUse { + guard let raw, raw != noContactWatermark else { return .none } + let referenceSeconds = UInt32(referenceNow.timeIntervalSince1970) + let maxSkew = UInt32(contactWatermarkPlausibilitySkew) + let upperBound = referenceSeconds > UInt32.max - maxSkew + ? UInt32.max + : referenceSeconds + maxSkew + if raw > upperBound { + return .invalid(stored: raw) + } + return .incremental(raw) + } + // MARK: - Full Sync /// Performs full sync of contacts, channels, and messages from device. @@ -42,6 +114,8 @@ extension SyncCoordinator { channelSyncConfig: ChannelSyncConfig = .none, platformName: String = "unknown" ) async throws -> FullSyncResult { + try await waitForAdvertContactSync() + // Prevent concurrent syncs — actor-local flag avoids the TOCTOU window // that existed when guarding via `await state.isSyncing` guard !isSyncInProgress else { @@ -283,6 +357,8 @@ extension SyncCoordinator { ) async throws -> FullSyncResult { logger.info("Connection established for device \(radioID)") + try await waitForAdvertContactSync() + // 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. @@ -302,11 +378,25 @@ extension SyncCoordinator { startSuppressionWatchdog(notificationService: dependencies.notificationService) do { - // Defer advert-driven contact fetches during sync to avoid BLE contention + // Defer advert-driven contact sync during full sync to avoid BLE contention await dependencies.advertisementService.setSyncingContacts(true) - // 1. Wire message handlers first (before events can arrive) + // 1. Wire message handlers and advert delta-sync first (before events can arrive). + // Delta-sync wiring must not wait until after runFullSync: if the initial sync + // throws, performResync never rewires handlers and the handler would stay dead. await wireMessageHandlers(dependencies: dependencies, radioID: radioID) + let dataStore = dependencies.dataStore + let contactService = dependencies.contactService + await dependencies.advertisementService.setDeltaSyncHandler { [weak self] fullRefetch in + // A released coordinator means the connection is gone; retrying cannot help. + guard let self else { return .failed } + return await self.performAdvertContactSync( + fullRefetch: fullRefetch, + radioID: radioID, + dataStore: dataStore, + contactService: contactService + ) + } // Clean up legacy blocked sender messages still in DB from older app versions await deleteBlockedSenderMessages(radioID: radioID, dataStore: dependencies.dataStore) @@ -383,11 +473,20 @@ extension SyncCoordinator { "[Sync] onDisconnected called - syncState: \(String(describing: currentState)), hasEndedSyncActivity: \(hasEndedSyncActivity)" ) - // Safety net: clear sync guard flag on disconnect + // Safety net: clear sync guard flags on disconnect and wake any waiters so + // pull-to-refresh / full-sync cannot stay suspended after the connection drops. if isSyncInProgress { logger.warning("isSyncInProgress still true at disconnect — clearing as safety net") } isSyncInProgress = false + advertContactSyncActive = false + manualContactSyncActive = false + resumeAllAdvertSyncWaiters() + + // The next session must prove its own full contact fetch before delta sync runs. + fullContactSyncCompletedRadioID = nil + invalidWatermarkRecoveryRadioID = nil + invalidWatermarkRecoveryExhaustedLoggedRadioID = nil // Note: pending reactions are not cleared on disconnect - they persist for the app session // This handles temporary BLE disconnects without losing queued reactions @@ -432,32 +531,80 @@ extension SyncCoordinator { // 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)))") + // At capacity, force a pruning contact fetch (`since == nil`). Offline + // eviction is invisible to the incremental watermark. Keep this contacts- + // only: do not set `forceFullSync`, which also forces channel sync. + // Decide here, before the watermark switch, so the one-shot invalid- + // watermark recovery token is not spent. Exclude the virtual V-contact + // from the count. Skip when maxContacts is unknown or zero; a count-read + // failure leaves atCapacity false. + var atCapacity = false + var realContactCount = 0 + if let maxContacts = device?.maxContacts, maxContacts > 0 { + do { + let keys = try await dataStore.fetchContactPublicKeys(radioID: radioID) + realContactCount = keys.count + if let selfPublicKey = device?.publicKey, + let vContactKey = VContactIdentity.publicKey(forSelfPublicKey: selfPublicKey), + keys.contains(vContactKey) { + realContactCount -= 1 + } + atCapacity = realContactCount >= Int(maxContacts) + } catch { + logger.error("Failed to read local contact count for capacity check: \(error)") + } + } + + // Phase 1: Contacts (incremental unless forced full or at capacity) + var ranInvalidWatermarkRecovery = false + let lastContactSync: Date? + if forceFullSync || atCapacity { + lastContactSync = nil + if forceFullSync { + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=forceFullSync) — local contacts not on device will be pruned" + ) + } else { + let maxContacts = device?.maxContacts ?? 0 + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=at capacity (local \(realContactCount) of max \(maxContacts))) — local contacts not on device will be pruned" + ) + } } 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") + switch Self.contactWatermarkUse(fromLastContactSync: device?.lastContactSync) { + case .none: + lastContactSync = nil + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=no watermark) — local contacts not on device will be pruned" + ) + case let .incremental(watermark): + lastContactSync = Self.incrementalSince(watermark: watermark) + case let .invalid(stored): + // Connect full-sync may prune (`since == nil`). Advert recovery must not — + // see performAdvertContactSync. Bound to one recovery per radio lifetime. + if invalidWatermarkRecoveryRadioID == radioID { + lastContactSync = Self.incrementalSince(watermark: stored) + logInvalidWatermarkRecoveryExhaustedIfNeeded(radioID: radioID, stored: stored) + } else { + ranInvalidWatermarkRecovery = true + lastContactSync = nil + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=invalid watermark \(stored) exceeds phone reference + \(Int(Self.contactWatermarkPlausibilitySkew))s) — one-shot recovery, store not rewritten" + ) + } + } } - 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() + _ = try await syncContactsPhase( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + since: lastContactSync, + forceFullSync: forceFullSync + ) - // Update lastContactSync watermark for future incremental syncs - if contactResult.lastSyncTimestamp > 0 { - try await dataStore.updateDeviceLastContactSync( - radioID: radioID, - timestamp: contactResult.lastSyncTimestamp - ) + if ranInvalidWatermarkRecovery { + invalidWatermarkRecoveryRadioID = radioID } // Update RxLogService with contact public keys for direct message decryption @@ -611,6 +758,251 @@ extension SyncCoordinator { ) } + /// Logs once when invalid-watermark recovery is spent and rounds fall back to + /// the stored stamp (pathological residual far-future lastmod table). + private func logInvalidWatermarkRecoveryExhaustedIfNeeded(radioID: UUID, stored: UInt32) { + guard invalidWatermarkRecoveryExhaustedLoggedRadioID != radioID else { return } + invalidWatermarkRecoveryExhaustedLoggedRadioID = radioID + logger.notice( + "[Sync] Invalid watermark recovery exhausted for radio \(radioID.uuidString): stored \(stored) still implausible — using stored stamp; manual contact refresh required" + ) + } + + /// Runs contact sync and writes back the watermark when the result carries one. + /// Shared by full connect sync and advert-driven delta sync. + @discardableResult + private func syncContactsPhase( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + since: Date?, + forceFullSync: Bool = false + ) async throws -> ContactSyncResult { + // Caller already logged prune-full cases; epoch-0 prune-free fetch is silent here. + if let watermark = since { + logger.info("[Sync] Phase start: contacts (incremental, watermark=\(watermark.formatted(.iso8601)))") + } + + let contactStart = ContinuousClock.now + let contactResult = try await contactService.syncContacts(radioID: radioID, since: since) + 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() + + if contactResult.lastSyncTimestamp > 0 { + try await dataStore.updateDeviceLastContactSync( + radioID: radioID, + timestamp: contactResult.lastSyncTimestamp + ) + } + if since == nil { + fullContactSyncCompletedRadioID = radioID + } + return contactResult + } + + /// Suspends while an advert-driven delta sync holds the claim. + /// + /// Background advert work must not turn a full sync, connection setup, or + /// channel-only retry into a silent skip, and must not interleave its + /// `ContactServiceEvent.syncProgress` events with a user-initiated refresh. + /// + /// Throws `CancellationError` when the calling task is cancelled, and + /// `SyncCoordinatorError.syncFailed` when `timeout` elapses while the claim + /// is still held. On timeout the caller must not proceed into the radio + /// pipeline (that races progress events) and must not return a silent skip + /// for user-initiated work (the wait exists to stop that). Surface an error + /// so the spinner stops and the user can retry. + func waitForAdvertContactSync( + timeout: Duration? = nil + ) async throws { + let bound = timeout + ?? advertContactSyncWaitTimeoutOverride + ?? Self.advertContactSyncWaitTimeout + let deadline = ContinuousClock.now + bound + + while isSyncInProgress, advertContactSyncActive { + try Task.checkCancellation() + + let remaining = deadline - ContinuousClock.now + if remaining <= .zero { + logger.warning("Timed out waiting for advert contact sync to release claim") + throw SyncCoordinatorError.syncFailed(Self.advertContactSyncWaitTimedOutMessage) + } + + let waiterID = nextAdvertSyncWaiterID + nextAdvertSyncWaiterID &+= 1 + try await waitForAdvertClaimRelease(id: waiterID, timeout: remaining) + } + } + + /// Waits out an advert claim, then marks a user-initiated contact refresh + /// active in the same actor turn so no delta can interleave between observe + /// and claim (separate wait and claim hops leave that window). + func claimManualContactSync( + timeout: Duration? = nil + ) async throws { + try await waitForAdvertContactSync(timeout: timeout) + manualContactSyncActive = true + } + + /// Marks a user-initiated contact refresh so advert delta sync returns `.busy`. + func setManualContactSyncActive(_ active: Bool) { + manualContactSyncActive = active + } + + /// Test hook: shortens the advert claim wait bound used by default parameters. + func setAdvertContactSyncWaitTimeoutOverride(_ timeout: Duration?) { + advertContactSyncWaitTimeoutOverride = timeout + } + + /// Suspends until the advert claim clears, the wait is cancelled, or `timeout` elapses. + private func waitForAdvertClaimRelease(id: UInt64, timeout: Duration) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await self.suspendUntilAdvertClaimReleased(id: id) + } + group.addTask { + try await Task.sleep(for: timeout) + throw SyncCoordinatorError.syncFailed(Self.advertContactSyncWaitTimedOutMessage) + } + // First finisher wins: claim release, cancel, or timeout. + try await group.next() + group.cancelAll() + } + } + + /// Parks one waiter until resume, cancel, or timeout-driven cancellation. + private func suspendUntilAdvertClaimReleased(id: UInt64) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + // Claim may have cleared between the while check and registration. + if !(isSyncInProgress && advertContactSyncActive) { + continuation.resume() + return + } + advertSyncWaiters.append(AdvertSyncWaiter(id: id, continuation: continuation)) + } + } onCancel: { + Task { await self.finishAdvertSyncWaiter(id: id, error: CancellationError()) } + } + } + + /// Resumes one waiter if it is still registered (cancel/timeout path). + private func finishAdvertSyncWaiter(id: UInt64, error: Error) { + guard let index = advertSyncWaiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = advertSyncWaiters.remove(at: index) + waiter.continuation.resume(throwing: error) + } + + /// Resumes every advert-claim waiter. Used when the claim clears or on disconnect. + private func resumeAllAdvertSyncWaiters() { + let waiters = advertSyncWaiters + advertSyncWaiters = [] + for waiter in waiters { + waiter.continuation.resume() + } + } + + /// Advert-driven contact delta sync. Claims `isSyncInProgress` as advert-owned + /// so full sync waits rather than skips. + /// + /// Both modes need proof that a full contact fetch already succeeded for this + /// radio. Without it, writing a watermark here would make later full syncs + /// incremental and leave ghost contacts the device no longer has. An empty + /// radio stamps no watermark, so a completed full fetch grants entry on its + /// own and the round fetches from epoch zero until the first contact stamps one. + /// + /// - Parameter fullRefetch: When true, uses the epoch-0 prune-free full fetch. + /// When false, uses the stored watermark. + func performAdvertContactSync( + fullRefetch: Bool, + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol + ) async -> AdvertContactSyncOutcome { + guard !manualContactSyncActive else { + logger.info("Advert contact sync deferred because a manual contact refresh is active") + return .busy + } + guard !isSyncInProgress else { + logger.info("Advert contact sync deferred because sync is already active") + return .busy + } + isSyncInProgress = true + advertContactSyncActive = true + defer { + isSyncInProgress = false + advertContactSyncActive = false + resumeAllAdvertSyncWaiters() + } + + do { + let device = try await dataStore.fetchDevice(radioID: radioID) + let watermarkUse = Self.contactWatermarkUse(fromLastContactSync: device?.lastContactSync) + let hasCompletedFullSync = fullContactSyncCompletedRadioID == radioID + // `.invalid` still proves a prior stamp exists, so recovery may run. + let isReady = watermarkUse != .none || hasCompletedFullSync + guard isReady else { + logger.notice( + "[Sync] Advert contact sync skipped: no contact watermark yet (connect sync contacts phase has not succeeded)" + ) + return .notReady + } + + var ranInvalidWatermarkRecovery = false + let since: Date? + if fullRefetch { + logger.info("[Sync] Advert contact sync: prune-free full refetch") + since = Self.pruneFreeFullFetchSince + } else { + switch watermarkUse { + case .none: + logger.info("[Sync] Advert contact sync: prune-free full fetch (full sync found no contacts to stamp)") + since = Self.pruneFreeFullFetchSince + case let .incremental(watermark): + since = Self.incrementalSince(watermark: watermark) + case let .invalid(stored): + // Prune-free epoch-0 only. `since == nil` would delete local rows the + // device omits; connect full-sync may prune, background advert must not. + // One recovery full fetch per radio per coordinator lifetime; further + // rounds use the stored stamp so residual far-future lastmods cannot + // re-stream the whole table every debounce. + if invalidWatermarkRecoveryRadioID == radioID { + since = Self.incrementalSince(watermark: stored) + logInvalidWatermarkRecoveryExhaustedIfNeeded(radioID: radioID, stored: stored) + } else { + ranInvalidWatermarkRecovery = true + logger.notice( + "[Sync] Advert contact sync: invalid watermark \(stored) exceeds phone reference + \(Int(Self.contactWatermarkPlausibilitySkew))s — one-shot prune-free full recovery, store not rewritten" + ) + since = Self.pruneFreeFullFetchSince + } + } + } + + _ = try await syncContactsPhase( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + since: since + ) + if ranInvalidWatermarkRecovery { + invalidWatermarkRecoveryRadioID = radioID + } + return .synced + } catch { + logger.warning("Advert contact sync failed: \(error.localizedDescription)") + return .failed + } + } + /// Retries only unresolved channel indices without replaying contacts/messages. @discardableResult func retryChannels( @@ -622,6 +1014,29 @@ extension SyncCoordinator { return ChannelSyncResult(channelsSynced: 0, errors: []) } + do { + try await waitForAdvertContactSync() + } catch is CancellationError { + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError(index: $0, errorType: .transportError, description: "Retry cancelled") + } + ) + } catch { + logger.warning("Channel-only retry timed out waiting for advert contact sync") + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError( + index: $0, + errorType: .circuitBreaker, + description: Self.advertContactSyncWaitTimedOutMessage + ) + } + ) + } + guard !isSyncInProgress else { logger.info("Channel-only retry skipped because sync is already active") return ChannelSyncResult( diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift index 63305c89d..aba4c68cd 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift @@ -119,6 +119,46 @@ public actor SyncCoordinator { /// that existed when guarding via the `@MainActor`-isolated `state` property. var isSyncInProgress = false + /// True while `performAdvertContactSync` holds `isSyncInProgress`. Full sync, + /// connection setup, and channel-only retry wait for this claim instead of skipping. + var advertContactSyncActive = false + + /// True while a user-initiated contact refresh holds the radio pipeline. + /// Advert delta sync returns `.busy` so it retries on the debounce rather + /// than racing progress events with pull-to-refresh. + var manualContactSyncActive = false + + /// Continuations held by `waitForAdvertContactSync` until the advert claim clears. + var advertSyncWaiters: [AdvertSyncWaiter] = [] + + /// Monotonic id for advert-sync waiters so cancel/timeout can resume one waiter. + var nextAdvertSyncWaiterID: UInt64 = 0 + + /// Optional override for the advert claim wait bound. Tests set a short value; + /// production leaves this nil so `advertContactSyncWaitTimeout` applies. + var advertContactSyncWaitTimeoutOverride: Duration? + + /// One suspended waiter for the advert contact-sync claim. + struct AdvertSyncWaiter { + let id: UInt64 + let continuation: CheckedContinuation + } + + /// Radio whose full contact fetch (`since == nil`) last completed. An empty + /// contact table stamps no watermark, so the zero sentinel alone cannot separate + /// "no full sync yet" from "nothing to stamp". Advert delta sync needs that + /// difference to run at all. + var fullContactSyncCompletedRadioID: UUID? + + /// Radio that already spent its one invalid-watermark recovery full fetch this + /// coordinator lifetime. Bounds residual far-future lastmod tables so advert + /// delta cannot re-stream the whole contact table every debounce forever. + /// Manual pull-to-refresh and `forceFullSync` do not consult this latch. + var invalidWatermarkRecoveryRadioID: UUID? + + /// Radio for which the exhausted-recovery notice was already logged once. + var invalidWatermarkRecoveryExhaustedLoggedRadioID: UUID? + /// Cached blocked names (contacts + channel senders) for O(1) lookup in message handlers private var blockedNames: Set = [] @@ -364,7 +404,7 @@ public actor SyncCoordinator { // MARK: - Timestamp Correction /// Maximum acceptable time in the future for a sender timestamp (5 minutes) - private static let timestampToleranceFuture: TimeInterval = 5 * 60 + 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 diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift index 053ae33cf..0dffd94b0 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift @@ -238,32 +238,36 @@ extension BLEStateMachine { ) // didFailToConnect consumes the OS pending connect, and a suspended app's - // watchdog cannot retry, so a transient failure re-issues the connect and - // stays in the episode; the reconnect policy owns the transient-vs-escalate - // classification, including the encryption-timeout grace. + // watchdog cannot retry, so a non-definitive failure re-issues the connect + // and stays in the episode unless the reconnect policy escalates. if case let .autoReconnecting(expected, _, _) = phase, expected.identifier == peripheral.identifier { - switch reconnectPolicy.resolveConnectFailure(deviceID: peripheral.identifier, error: error, now: Date()) { + switch reconnectPolicy.resolveConnectFailure( + deviceID: peripheral.identifier, + error: error, + now: Date(), + appActive: isAppActive + ) { case let .retryPendingConnect(failureCount, budget): logger.info("[BLE] Transient auto-reconnect connect failure (\(failureCount)/\(budget)) for \(peripheral.identifier.uuidString.prefix(8)); re-issuing pending connect") - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionEnableAutoReconnect: true - ] - centralManager.connect(peripheral, options: options) + reissueAutoReconnectConnect(peripheral) + + case let .continueEpisodeAfterBudget(reason): + switch reason { + case let .fringeEncryptionGraced(verifiedAge): + logger.warning("[BLE] Encryption-timeout budget exhausted for \(peripheral.identifier.uuidString.prefix(8)); bond verified \(Self.verifiedAgeDescription(verifiedAge)), within grace; re-issuing pending connect") + case .backgroundHold: + logger.warning("[BLE] Auto-reconnect connect failures exhausted budget for \(peripheral.identifier.uuidString.prefix(8)) while inactive; re-issuing pending connect") + } + reissueAutoReconnectConnect(peripheral) case let .tearDown(teardownError, reason): switch reason { case .definitiveBondFailure: logger.warning("Auto-reconnect failed with definitive auth error for \(peripheral.identifier) - transitioning to idle") - case let .fringeEncryptionGraced(verifiedAge): - logger.warning("[BLE] Encryption-timeout budget exhausted for \(peripheral.identifier.uuidString.prefix(8)); bond verified \(Self.verifiedAgeDescription(verifiedAge)), within grace; classifying as transient") case let .bondSuspect(verifiedAge): logger.warning("[BLE] Encryption-timeout budget exhausted for \(peripheral.identifier.uuidString.prefix(8)); bond verified \(Self.verifiedAgeDescription(verifiedAge)), outside grace; escalating to bond-suspect") case .retryBudgetExhausted: - break - } - if case .definitiveBondFailure = reason {} else { logger.warning("Auto-reconnect connect failures exhausted budget for \(peripheral.identifier) - transitioning to idle") } transition(to: .idle) @@ -283,6 +287,14 @@ extension BLEStateMachine { continuation.resume(throwing: ReconnectPolicy.makeConnectionError(error)) } + private func reissueAutoReconnectConnect(_ peripheral: CBPeripheral) { + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionEnableAutoReconnect: true + ] + centralManager.connect(peripheral, options: options) + } + /// Renders a bond-verification age for debug-log export, so a later export /// can distinguish a graced fringe episode from a graced dead bond. static func verifiedAgeDescription(_ age: TimeInterval?) -> String { diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift index 882bd9d7e..a4759fa0c 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift @@ -184,9 +184,9 @@ actor BLEStateMachine: BLEStateMachineProtocol { /// 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 + /// Confirmed foreground. Starts false: a BLE process relaunch is already + /// background, and scene `.onChange` does not fire for the initial phase. + private(set) var isAppActive = false /// Tracks whether CBCentralManager has been created private var isActivated = false @@ -446,10 +446,9 @@ actor BLEStateMachine: BLEStateMachineProtocol { onAutoReconnecting = handler } - /// Records that a device's bond completed a verified encrypted session, so an - /// exhausted encryption-timeout budget within the grace window classifies as - /// transient. `ConnectionManager` seeds the persisted date at wiring time and - /// pushes a fresh one after every verified session. + /// Records a verified encrypted session. An exhausted encryption-timeout + /// budget within the grace window then continues the episode rather than + /// tearing down. `ConnectionManager` seeds and refreshes this stamp. func recordBondVerification(deviceID: UUID, at date: Date) { reconnectPolicy.recordBondVerification(deviceID: deviceID, at: date) } diff --git a/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift b/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift index 91981b861..3f1680386 100644 --- a/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift +++ b/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift @@ -19,17 +19,13 @@ struct ReconnectPolicy { /// genuinely wedged-but-connected link still tears down eventually. static let maxDiscoveryTimeoutExtensions = 2 - /// Max consecutive `didFailToConnect` callbacks tolerated within one - /// auto-reconnect episode before the machine gives up and notifies loss. - /// Bounds re-arming so a radio that fast-rejects every connect cannot spin here. + /// Consecutive `didFailToConnect` callbacks in one auto-reconnect episode + /// before `resolveConnectFailure` classifies hold versus tear-down. static let maxAutoReconnectConnectFailures = 5 - /// How long after a verified encrypted session an exhausted encryption-timeout - /// budget is still treated as transient rather than a suspect bond. Encryption - /// timeouts at the edge of BLE range are indistinguishable from an invalidated - /// bond attempt-by-attempt; a bond that completed an encrypted session this - /// recently is near-certainly healthy, while a genuinely dead bond can never - /// refresh the verification and escalates once the grace elapses. + /// After a verified encrypted session, an exhausted encryption-timeout + /// majority returns `.continueEpisodeAfterBudget` rather than `.bondSuspect`. + /// Outside this window a dead bond escalates only while the app is active. static let bondVerificationGraceInterval: TimeInterval = 6 * 60 * 60 // MARK: - State @@ -39,8 +35,7 @@ struct ReconnectPolicy { var autoReconnectConnectFailures = 0 /// How many of `autoReconnectConnectFailures` carried `CBError.encryptionTimedOut`. - /// A majority routes an exhausted episode to guided re-pair, since repeated - /// encryption timeouts are the ambiguous in-app signature of an invalidated bond. + /// A majority is the ambiguous dead-bond signature used at budget exhaust. var encryptionTimedOutConnectFailures = 0 /// Number of times a discovery watchdog has deferred teardown within the @@ -98,27 +93,40 @@ struct ReconnectPolicy { enum ConnectFailureDecision { /// Re-issue the pending connect; the episode continues. case retryPendingConnect(failureCount: Int, budget: Int) + /// Budget spent: re-issue `connect` and keep the episode. + /// `didFailToConnect` already consumed the pending connect; tearing + /// down would leave only a watchdog whose sleep freezes when suspended. + case continueEpisodeAfterBudget(reason: BudgetHoldReason) /// End the episode, surfacing `error` through `onDisconnection`. case tearDown(error: BLEError, reason: TeardownReason) } + /// Why the episode continued after the connect-failure budget was spent. + /// `verifiedAge` is the time since the bond's last verified encrypted + /// session at decision time, nil when it never verified. + enum BudgetHoldReason { + case fringeEncryptionGraced(verifiedAge: TimeInterval?) + case backgroundHold + } + /// Why a connect-failure `.tearDown` was chosen; drives the diagnostic log /// line. `verifiedAge` is the time since the bond's last verified encrypted /// session at decision time, nil when it never verified. enum TeardownReason { case definitiveBondFailure - case fringeEncryptionGraced(verifiedAge: TimeInterval?) case bondSuspect(verifiedAge: TimeInterval?) case retryBudgetExhausted } - /// `didFailToConnect` arrived while auto-reconnecting. A transient failure - /// re-issues the connect and stays in the episode. Only a definitive bond - /// failure tears down at once; exhausting the bounded budget on encryption - /// timeouts escalates to auth failure — unless the bond verified an - /// encrypted session recently — so an invalidated bond still reaches guided - /// re-pair. - mutating func resolveConnectFailure(deviceID: UUID, error: Error?, now: Date) -> ConnectFailureDecision { + /// `didFailToConnect` while auto-reconnecting. A definitive bond failure + /// tears down at once; otherwise re-issue until the budget, then hold + /// (recently verified, or inactive) or tear down (active and unshielded). + mutating func resolveConnectFailure( + deviceID: UUID, + error: Error?, + now: Date, + appActive: Bool + ) -> ConnectFailureDecision { if Self.isDefinitiveAuthFailure(error) { resetFailureTallies() return .tearDown(error: .authenticationFailed, reason: .definitiveBondFailure) @@ -136,27 +144,25 @@ struct ReconnectPolicy { ) } - // An encryption-timeout majority is the ambiguous signature of an - // invalidated bond, but it is also what a healthy bond produces when the - // user lingers at the edge of BLE range. A recently verified bond tears - // down as transient (the watchdog keeps retrying); a dead bond can never - // refresh its verification, so it still escalates once the grace elapses. + // Encryption-timeout majority is both a dead-bond signature and fringe-range + // noise. Keep the pending connect when recently verified or inactive; + // escalate to `.bondSuspect` only when the app is active and grace has elapsed. let majorityEncryptionTimeouts = encryptionTimedOutConnectFailures * 2 > autoReconnectConnectFailures resetFailureTallies() - guard majorityEncryptionTimeouts else { - return .tearDown(error: Self.makeConnectionError(error), reason: .retryBudgetExhausted) - } - let lastVerified = bondVerificationDates[deviceID] let verifiedAge = lastVerified.map { now.timeIntervalSince($0) } - if Self.isBondRecentlyVerified(lastVerified: lastVerified, now: now) { - return .tearDown( - error: .connectionFailed("Encryption timed out repeatedly near range limit"), - reason: .fringeEncryptionGraced(verifiedAge: verifiedAge) - ) + + if majorityEncryptionTimeouts, Self.isBondRecentlyVerified(lastVerified: lastVerified, now: now) { + return .continueEpisodeAfterBudget(reason: .fringeEncryptionGraced(verifiedAge: verifiedAge)) + } + if !appActive { + return .continueEpisodeAfterBudget(reason: .backgroundHold) + } + if majorityEncryptionTimeouts { + return .tearDown(error: .authenticationFailed, reason: .bondSuspect(verifiedAge: verifiedAge)) } - return .tearDown(error: .authenticationFailed, reason: .bondSuspect(verifiedAge: verifiedAge)) + return .tearDown(error: Self.makeConnectionError(error), reason: .retryBudgetExhausted) } // MARK: - Discovery-stall classification @@ -230,9 +236,8 @@ struct ReconnectPolicy { /// Maps a CoreBluetooth error to a typed BLEError. The CBATTError auth/encryption /// family and `CBError.peerRemovedPairingInformation` are definitive bond failures /// mapped to `.authenticationFailed`, so detection survives iOS localizing the - /// description. A lone `CBError.encryptionTimedOut` is transient and stays - /// `.connectionFailed`; connect-failure resolution escalates it only when it - /// dominates an exhausted auto-reconnect retry budget. + /// description. A lone `CBError.encryptionTimedOut` stays `.connectionFailed`; + /// `resolveConnectFailure` holds or escalates it from the exhausted budget. static func makeConnectionError(_ error: Error?, fallback: String = "Unknown error") -> BLEError { if let nsError = error as NSError? { if nsError.domain == CBATTErrorDomain { diff --git a/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift index 7357b5c30..22a4ed22d 100644 --- a/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift @@ -6,8 +6,8 @@ import Testing // MARK: - Helpers private enum AdvertisementServiceTestError: Error { - case getContactFailed case deadlineExceeded(String) + case storeUnavailable } private func makePublicKey(seed: UInt8) -> Data { @@ -19,7 +19,11 @@ private func makeMeshContact( name: String = "Node", type: ContactType = .chat, outPathLength: UInt8 = 0, - outPath: Data = Data() + outPath: Data = Data(), + latitude: Double = 0, + longitude: Double = 0, + lastAdvertTimestamp: Date = Date(timeIntervalSince1970: 1_700_000_000), + lastModified: Date = Date(timeIntervalSince1970: 1_700_000_100) ) -> MeshContact { MeshContact( id: publicKey.hexString, @@ -29,17 +33,21 @@ private func makeMeshContact( outPathLength: outPathLength, outPath: outPath, advertisedName: name, - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 1_700_000_100) + lastAdvertisement: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified ) } private func makeContactFrame( publicKey: Data, name: String = "LocalContact", - type: ContactType = .chat + type: ContactType = .chat, + latitude: Double = 0, + longitude: Double = 0, + lastAdvertTimestamp: UInt32 = 1_700_000_000, + lastModified: UInt32 = 1_700_000_100 ) -> ContactFrame { ContactFrame( publicKey: publicKey, @@ -48,10 +56,10 @@ private func makeContactFrame( outPathLength: 0, outPath: Data(), name: name, - lastAdvertTimestamp: 1_700_000_000, - latitude: 0, - longitude: 0, - lastModified: 1_700_000_100 + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified ) } @@ -70,6 +78,102 @@ private func waitUntil( return await predicate() } +private actor HandlerRecorder { + private(set) var calls: [Bool] = [] + private var results: [AdvertContactSyncOutcome] = [] + private var persistFrames: [ContactFrame] = [] + private let store: any PersistenceStoreProtocol + private let radioID: UUID + + init(store: any PersistenceStoreProtocol, radioID: UUID) { + self.store = store + self.radioID = radioID + } + + func enqueueResult(_ outcome: AdvertContactSyncOutcome) { + results.append(outcome) + } + + func enqueuePersist(_ frame: ContactFrame) { + persistFrames.append(frame) + } + + func handle(fullRefetch: Bool) async -> AdvertContactSyncOutcome { + calls.append(fullRefetch) + let outcome = results.isEmpty ? AdvertContactSyncOutcome.synced : results.removeFirst() + // Only persist on success so a failed call cannot leave a row that + // masks a missing re-merge of pending keys. + if outcome == .synced, !persistFrames.isEmpty { + let frame = persistFrames.removeFirst() + _ = try? await store.saveContact(radioID: radioID, from: frame) + } + return outcome + } + + var callCount: Int { + calls.count + } + + var fullRefetchFlags: [Bool] { + calls + } +} + +private actor EventCounter { + private(set) var newContactCount = 0 + private(set) var contactUpdatedCount = 0 + private(set) var conversationsChangedCount = 0 + private(set) var adoptedContactIDs: [UUID] = [] + + func note(_ event: AdvertisementEvent) { + switch event { + case .newContactDiscovered: + newContactCount += 1 + case .contactUpdated: + contactUpdatedCount += 1 + case .conversationsChanged: + conversationsChangedCount += 1 + case let .orphanDirectMessagesAdopted(contactIDs): + adoptedContactIDs.append(contentsOf: contactIDs) + default: + break + } + } +} + +/// Records `fullRefetch` flags from a custom delta-sync handler. +private actor CallFlagRecorder { + private(set) var flags: [Bool] = [] + + func note(_ fullRefetch: Bool) { + flags.append(fullRefetch) + } +} + +/// Fails every round up to the cap, and on the final failing round records a +/// fresh advert key mid-flight — reproducing an 0x80 that lands while the last +/// failing handler is awaited. Rounds past the cap succeed so the fresh key drains. +private actor CapRoundInjector { + private let service: AdvertisementService + private let freshKey: Data + private let cap: Int + private(set) var calls = 0 + + init(service: AdvertisementService, freshKey: Data, cap: Int) { + self.service = service + self.freshKey = freshKey + self.cap = cap + } + + func handle() async -> AdvertContactSyncOutcome { + calls += 1 + if calls == cap { + await service.recordPendingAdvertKey(freshKey) + } + return calls > cap ? .synced : .failed + } +} + // MARK: - Suite @Suite("AdvertisementService Tests", .serialized) @@ -86,9 +190,18 @@ struct AdvertisementServiceTests { private func makeService( session: MockMeshCoreSession, - store: any PersistenceStoreProtocol + store: any PersistenceStoreProtocol, + advertSyncDebounce: Duration = .zero, + advertSyncMinInterval: Duration = .zero, + advertSyncBusyBackoff: Duration = .zero ) -> AdvertisementService { - AdvertisementService(session: session, dataStore: store) + AdvertisementService( + session: session, + dataStore: store, + advertSyncDebounce: advertSyncDebounce, + advertSyncMinInterval: advertSyncMinInterval, + advertSyncBusyBackoff: advertSyncBusyBackoff + ) } private func startMonitoring(_ service: AdvertisementService, session: MockMeshCoreSession) async { @@ -99,705 +212,2167 @@ struct AdvertisementServiceTests { #expect(subscribed) } - // MARK: - Drain-loop non-blocking + private func installHandler( + _ service: AdvertisementService, + recorder: HandlerRecorder + ) async { + await service.setDeltaSyncHandler { fullRefetch in + await recorder.handle(fullRefetch: fullRefetch) + } + } + + // MARK: - Rollback cascade safety @Test - func `held getContact for A does not block contactDeleted for B`() async throws { + func `pathUpdate cancel after save does not cascade-delete messages`() async throws { + // A contact re-saved mid-round after 0x8F must not cascade-wipe a DM + // that landed before rollback. let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let keyA = makePublicKey(seed: 0xA1) - let keyB = makePublicKey(seed: 0xB2) - - // Seed contact B locally so 0x8F can delete it. - let frameB = makeContactFrame(publicKey: keyB, name: "ContactB") - let contactBID = try await store.saveContact(radioID: radioID, from: frameB).id - - await session.setStubbedContact(makeMeshContact(publicKey: keyA, name: "ContactA"), for: keyA) - await session.holdNextGetContact(for: keyA) + let key = makePublicKey(seed: 0xD4) + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + let saved = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "HasMessages") + ) + if let contactID = saved?.id { + try? await store.saveMessage( + MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID, text: "keep me") + ) + } + return .synced + } await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) - // Unknown-key advert for A parks getContact. - await session.yieldEvent(.advertisement(publicKey: keyA)) - let held = await waitUntil { - await session.isGetContactHeld(for: keyA) - } - #expect(held, "getContact for A should be held open") + // 0x8F while commit is held: no local row yet, so only the rollback key is set. + await session.yieldEvent(.contactDeleted(publicKey: key)) + try? await Task.sleep(for: .milliseconds(40)) + await hold.release() - // While A is held, 0x8F for B must still be processed (drain is non-blocking). - await session.yieldEvent(.contactDeleted(publicKey: keyB)) + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) - let deleted = await waitUntil(timeout: .seconds(1)) { - let contact = try? await store.fetchContact(id: contactBID) - return contact == nil - } + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let messages = try await store.fetchMessages(contactID: contact.id, limit: 50, offset: 0) + #expect(messages.count == 1, "messages must not be cascade-deleted on rollback") - await session.releaseGetContact(for: keyA) await service.stopEventMonitoring() - - #expect(deleted, "B must be deleted while A's getContact is still airborne") } - // MARK: - Ghost-contact cancel (same key, no local row) + // MARK: - Coalescing @Test - func `0x8F for unknown airborne key prevents ghost save`() async throws { + func `burst of adverts coalesces to one handler call`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + // Hold the first handler call so later adverts accumulate in pendingAdvertKeys + // instead of each completing a separate drain under .zero durations. + let hold = HandlerHold() + await service.setDeltaSyncHandler { fullRefetch in + let isFirst = await recorder.callCount == 0 + let result = await recorder.handle(fullRefetch: fullRefetch) + if isFirst { + await hold.waitUntilReleased() + } + return result + } - let keyA = makePublicKey(seed: 0xC3) - await session.setStubbedContact(makeMeshContact(publicKey: keyA, name: "Ghost"), for: keyA) - await session.holdNextGetContact(for: keyA) + let keys = (0..<5).map { makePublicKey(seed: UInt8(0xA0 &+ $0)) } + for key in keys { + let frame = makeContactFrame(publicKey: key, name: "K\(key.prefix(1).hexString)") + _ = try await store.saveContact(radioID: radioID, from: frame) + } await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keyA)) - - let held = await waitUntil { await session.isGetContactHeld(for: keyA) } + await session.yieldEvent(.advertisement(publicKey: keys[0])) + let held = await waitUntil { await hold.isWaiting } #expect(held) + #expect(await recorder.callCount == 1) - // 0x8F with no local Contact row — cancel must still be recorded. - await session.yieldEvent(.contactDeleted(publicKey: keyA)) - await session.releaseGetContact(for: keyA) - - // Wait until the held getContact has actually returned (not just been appended at hold entry). - let fetchReturned = await waitUntil { - await !session.isGetContactHeld(for: keyA) - } - #expect(fetchReturned, "getContact must complete after release") - - // Bound "commit finished and still nil": contact stays absent across several polls. - var sawContact = false - for _ in 0..<5 { - try? await Task.sleep(for: .milliseconds(20)) - if await (try? store.fetchContact(radioID: radioID, publicKey: keyA)) != nil { - sawContact = true - break - } + for key in keys.dropFirst() { + await session.yieldEvent(.advertisement(publicKey: key)) } + // Let the event loop record the remaining keys while the first drain is held. + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 1, "mid-hold adverts must not start a parallel handler") + await hold.release() + + // Mid-hold keys schedule exactly one follow-up pass after release. + let secondPass = await waitUntil { await recorder.callCount == 2 } + #expect(secondPass, "keys recorded mid-hold should schedule one second pass") + try? await Task.sleep(for: .milliseconds(40)) await service.stopEventMonitoring() - #expect(!sawContact, "cancelled fetch must not insert a ghost contact") + + #expect(await recorder.callCount == 2) + #expect(await session.getContactPublicKeys.isEmpty) } - // MARK: - Cancel after save (isNew false) + // MARK: - Known contact 0x80 @Test - func `pathUpdate cancel after save does not cascade-delete messages`() async throws { - // Hold saveContact after getContact returns so cancel lands post-save - // (isNew false must not cascade-delete messages). - let store = MockPersistenceStore() + func `known contact advert bumps lastHeard without getContact`() async throws { + let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0xD4) - let frame = makeContactFrame(publicKey: key, name: "HasMessages") - let contactID = try await store.saveContact(radioID: radioID, from: frame).id - try await store.saveMessage( - MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID, text: "keep me") - ) - - await session.setStubbedContact( - makeMeshContact(publicKey: key, name: "HasMessages", outPathLength: 1, outPath: Data([0xAA])), - for: key - ) - await store.holdNextSaveContact() + let key = makePublicKey(seed: 0xB1) + let frame = makeContactFrame(publicKey: key, name: "Known") + _ = try await store.saveContact(radioID: radioID, from: frame) + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + let before = Date().addingTimeInterval(-1) await startMonitoring(service, session: session) - await session.yieldEvent(.pathUpdate(publicKey: key)) - - let saveHeld = await waitUntil { await store.isSaveContactHeld } - #expect(saveHeld, "saveContact must be held so cancel can land after getContact") - - // Teardown cancels while save is airborne; isNew false must not deleteContact. - let completedBeforeRelease = await store.saveContactCompletedCount - await service.stopEventMonitoring() - await store.releaseSaveContact() + await session.yieldEvent(.advertisement(publicKey: key)) - // Wait for save to finish, then multi-poll so a late deleteContact cannot - // race past a single empty-deleted snapshot. - let saveFinished = await waitUntil { - await store.saveContactCompletedCount > completedBeforeRelease + let heard = await waitUntil { + let contact = try? await store.fetchContact(radioID: radioID, publicKey: key) + return (contact?.lastHeardTimestamp ?? 0) > 0 } - #expect(saveFinished, "saveContact must complete after release") + #expect(heard) - var sawCascadeDefect = false - for _ in 0..<5 { - try? await Task.sleep(for: .milliseconds(20)) - let contact = try? await store.fetchContact(id: contactID) - let messages = await (try? store.fetchMessages(contactID: contactID, limit: 50, offset: 0)) ?? [] - let deleted = await store.deletedContactIDs - if contact == nil || messages.count != 1 || !deleted.isEmpty { - sawCascadeDefect = true - break - } - } - #expect(!sawCascadeDefect, "contact, messages, and empty deletes must hold across the settle window") + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + #expect((contact.lastHeardTimestamp ?? 0) >= UInt32(before.timeIntervalSince1970)) - let messages = try await store.fetchMessages(contactID: contactID, limit: 50, offset: 0) - let contact = try await store.fetchContact(id: contactID) - #expect(contact != nil, "existing contact must survive cancelled path-update upsert") - #expect(messages.count == 1, "messages must not be cascade-deleted on cancelled upsert") - #expect(await store.deletedContactIDs.isEmpty, "isNew false must not invoke deleteContact") + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + let node = try #require(nodes.first { $0.publicKey == key }) + #expect(node.lastHeard >= before) + + let handlerRan = await waitUntil { await recorder.callCount >= 1 } + #expect(handlerRan) + #expect(await session.getContactPublicKeys.isEmpty) + await service.stopEventMonitoring() } - // MARK: - Reason upgrade + // MARK: - Unknown key 0x80 @Test - func `pathUpdate upgraded by advert saves DiscoveredNode`() async throws { + func `unknown key advert yields newContactDiscovered once after handler persists`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let key = makePublicKey(seed: 0xC2) + await recorder.enqueuePersist( + makeContactFrame(publicKey: key, name: "NewNode", latitude: 10, longitude: 20) + ) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0xE5) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Upgraded"), for: key) - await session.holdNextGetContact(for: key) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } await startMonitoring(service, session: session) - await session.yieldEvent(.pathUpdate(publicKey: key)) + await session.yieldEvent(.advertisement(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) + let discovered = await waitUntil { + await counter.newContactCount >= 1 + } + #expect(discovered) - // Upgrade reason while airborne. Yield and allow the drain loop to process - // the advert before releasing getContact so the upgrade is visible to the worker. + // Second advert for the same key must not re-notify as new. + await recorder.enqueuePersist( + makeContactFrame(publicKey: key, name: "NewNode", latitude: 11, longitude: 21) + ) await session.yieldEvent(.advertisement(publicKey: key)) + let secondHandler = await waitUntil { await recorder.callCount >= 2 } + #expect(secondHandler) try? await Task.sleep(for: .milliseconds(50)) - await session.releaseGetContact(for: key) - let nodesAppeared = await waitUntil { - let nodes = await (try? store.fetchDiscoveredNodes(radioID: radioID)) ?? [] - return nodes.contains { $0.publicKey == key } - } await service.stopEventMonitoring() - #expect(nodesAppeared, "advert reason upgrade must upsert DiscoveredNode") + service.finishEvents() + _ = await listener.result + + #expect(await counter.newContactCount == 1) + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + #expect(contact.name == "NewNode") } + // MARK: - 0x8A parity + @Test - func `advert during pathUpdate saveContact upgrades to DiscoveredNode`() async { - // Reason is pathUpdate at switch time; advert upgrades the queue entry while - // pathUpdate's saveContact is airborne. commitPathUpdateFetch never upserts - // DiscoveredNode, so Discover visibility depends on the upgrade path. - let store = MockPersistenceStore() + func `0x8A then 0x80 for same key notifies from each path`() async throws { + let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0xD3) + let mesh = makeMeshContact(publicKey: key, name: "Manual") - let key = makePublicKey(seed: 0xE6) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "MidCommit"), for: key) - await store.holdNextSaveContact() + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } await startMonitoring(service, session: session) - await session.yieldEvent(.pathUpdate(publicKey: key)) + await session.yieldEvent(.newContact(mesh)) - let saveHeld = await waitUntil { await store.isSaveContactHeld } - #expect(saveHeld, "pathUpdate saveContact must be held so advert can upgrade mid-commit") + let from8A = await waitUntil { await counter.newContactCount >= 1 } + #expect(from8A) - // Unknown-key advert upgrades the queue entry while pathUpdate save is airborne. - // PathUpdate inserts first, so the advert commit sees isNew false and emits - // .contactUpdated rather than .newContactDiscovered. + // 0x8A leaves a Discover row but no Contact row. The 0x80 delta round's + // pre-round snapshot therefore lacks the key; after the handler persists the + // Contact row it lands in insertedKeys and is announced separately from 0x8A. + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "Manual")) await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(50)) - await store.releaseSaveContact() - let nodesAppeared = await waitUntil { - let nodes = await (try? store.fetchDiscoveredNodes(radioID: radioID)) ?? [] - return nodes.contains { $0.publicKey == key } - } + // 0x8A yielded the first .contactUpdated; the second one lands after reconcile. + let reconciled = await waitUntil { await counter.contactUpdatedCount >= 2 } + #expect(reconciled) + try? await Task.sleep(for: .milliseconds(30)) + await service.stopEventMonitoring() - #expect(nodesAppeared, "mid-commit advert upgrade must upsert DiscoveredNode") + service.finishEvents() + _ = await listener.result + + #expect(await counter.newContactCount == 2) + #expect(await recorder.callCount == 1) } - // MARK: - One drainer / re-entrant barrier + // MARK: - Path update @Test - func `concurrent setSyncingContacts false performs one fetch per key`() async throws { + func `pathUpdate only triggers handler without Discover row`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0xF6) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Once"), for: key) - await session.holdNextGetContact(for: key) + let key = makePublicKey(seed: 0xE4) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } - await service.setSyncingContacts(true) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) + await session.yieldEvent(.pathUpdate(publicKey: key)) - // Two concurrent barrier callers. - async let barrier1: Void = service.setSyncingContacts(false) - async let barrier2: Void = service.setSyncingContacts(false) + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) + let updated = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(updated) - await session.releaseGetContact(for: key) - _ = await (barrier1, barrier2) + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty) + #expect(await session.getContactPublicKeys.isEmpty) - let fetchCount = await session.getContactPublicKeys.filter { $0 == key }.count await service.stopEventMonitoring() - #expect(fetchCount == 1, "one drainer must fetch each key once across re-entrant barriers") + service.finishEvents() + _ = await listener.result } - // MARK: - Cancel-guarded removal (re-enqueue while airborne) - @Test - func `0x8F then re-advert while airborne fetches again and saves`() async throws { + func `pathUpdate for known contact escalates when incremental leaves lastModified unchanged`() async throws { + // Radio RTC reset / clock step-back can stamp path lastmod at or below the + // stored watermark. Incremental GET_CONTACTS then returns nothing; known + // contacts never enter escalateMissingUnknownKeys. Without a post-round + // path check the out-path stays stale forever. let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let key = makePublicKey(seed: 0x17) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "ReAdded"), for: key) - await session.holdNextGetContact(for: key) + let key = makePublicKey(seed: 0xA1) + let oldLastMod: UInt32 = 1_700_000_100 + let oldPath = Data([0x01, 0x02]) + let newPath = Data([0x03, 0x04]) + let newLastMod: UInt32 = oldLastMod + 50 + _ = try await store.saveContact( + radioID: radioID, + from: ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 2, + outPath: oldPath, + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 10.0, + longitude: 20.0, + lastModified: oldLastMod + ) + ) + + let calls = CallFlagRecorder() + await service.setDeltaSyncHandler { fullRefetch in + await calls.note(fullRefetch) + // Incremental models an empty watermark filter (no row written). + // Only the escalated full refetch delivers the path update. + if fullRefetch { + _ = try? await store.saveContact( + radioID: radioID, + from: ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 2, + outPath: newPath, + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 10.5, + longitude: 20.5, + lastModified: newLastMod + ) + ) + } + return .synced + } await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) + await session.yieldEvent(.pathUpdate(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) + let escalated = await waitUntil(timeout: .seconds(3)) { + let flags = await calls.flags + return flags.count >= 2 && flags.contains(true) + } + #expect( + escalated, + "when incremental leaves lastModified unchanged, path must escalate to full refetch" + ) + #expect(await calls.flags.first == false) + #expect(await session.getContactPublicKeys.isEmpty, "must not reinstate per-key getContact") - // Cancel airborne fetch, then radio re-adds and advert re-enqueues before release. - // A cancelled fetch must leave the newer entry so the worker fetches again. - await session.yieldEvent(.contactDeleted(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) - await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(50)) - await session.releaseGetContact(for: key) + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact?.outPath == newPath) + #expect(contact?.lastModified == newLastMod) + #expect(contact?.latitude == 10.5) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } - let fetchCount = await session.getContactPublicKeys.filter { $0 == key }.count await service.stopEventMonitoring() - #expect(saved, "re-enqueued key must be saved after second fetch") - #expect(fetchCount == 2, "cancelled fetch must not strand the re-enqueued key") } @Test - func `0x8F re-advert then getContact throw does not strand re-enqueued key`() async throws { + func `pathUpdate for known contact does not escalate when incremental refreshes lastModified`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0xA2) + let oldLastMod: UInt32 = 1_700_000_100 + let newLastMod: UInt32 = oldLastMod + 10 + let newPath = Data([0xAA, 0xBB]) + _ = try await store.saveContact( + radioID: radioID, + from: ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 1, + outPath: Data([0x11]), + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 1.0, + longitude: 2.0, + lastModified: oldLastMod + ) + ) - let key = makePublicKey(seed: 0x18) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "ThrowThenOK"), for: key) - await session.holdNextGetContact(for: key) + await recorder.enqueueResult(.synced) + await recorder.enqueuePersist( + ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 2, + outPath: newPath, + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 1.0, + longitude: 2.0, + lastModified: newLastMod + ) + ) + await installHandler(service, recorder: recorder) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) - - await session.yieldEvent(.contactDeleted(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) - await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) + await session.yieldEvent(.pathUpdate(publicKey: key)) - // First release throws while cancelled — must not wipe the re-enqueued entry. - await session.setGetContactError(AdvertisementServiceTestError.getContactFailed, for: key) - await session.holdNextGetContact(for: key) - await session.releaseGetContact(for: key) + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + // Allow a second round if one were incorrectly scheduled. + try? await Task.sleep(for: .milliseconds(80)) + #expect(await recorder.callCount == 1, "successful path delivery must not escalate") + #expect(await recorder.fullRefetchFlags == [false]) + #expect(await !service.escalateToFullRefetch) - let heldAgain = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(heldAgain, "re-enqueued key must be fetched again after cancelled throw") - await session.setGetContactError(nil, for: key) - await session.releaseGetContact(for: key) + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact?.outPath == newPath) + #expect(contact?.lastModified == newLastMod) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } await service.stopEventMonitoring() - #expect(saved, "throw after cancel must not strand the re-enqueued key") } + // MARK: - Failure re-merge + @Test - func `0x8F re-advert then getContact nil does not strand re-enqueued key`() async throws { + func `handler failure remerges keys and retries`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let key = makePublicKey(seed: 0xF5) + // Unknown key: first fail leaves no row; second success persists + reconcile notifies. + // Proves re-merge kept the key — empty drained would skip Discover/newContact. + await recorder.enqueueResult(.failed) + await recorder.enqueueResult(.synced) + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "Retry")) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0x19) - let contact = makeMeshContact(publicKey: key, name: "NilThenOK") - await session.setStubbedContact(contact, for: key) - await session.holdNextGetContact(for: key) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } await startMonitoring(service, session: session) await session.yieldEvent(.advertisement(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) - await session.yieldEvent(.contactDeleted(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) - await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) + let retried = await waitUntil { await recorder.callCount >= 2 } + #expect(retried) - // First release returns nil while cancelled — must not wipe the re-enqueued entry. - await session.setStubbedContact(nil, for: key) - await session.holdNextGetContact(for: key) - await session.releaseGetContact(for: key) + let discovered = await waitUntil { await counter.newContactCount >= 1 } + #expect(discovered, "re-merged key must reach reconcile after successful retry") - let heldAgain = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(heldAgain, "re-enqueued key must be fetched again after cancelled nil") - await session.setStubbedContact(contact, for: key) - await session.releaseGetContact(for: key) + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact != nil) + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.contains { $0.publicKey == key }) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } await service.stopEventMonitoring() - #expect(saved, "nil after cancel must not strand the re-enqueued key") + service.finishEvents() + _ = await listener.result } - // MARK: - Throw drops and continues + barrier return + // MARK: - Syncing deferral @Test - func `getContact throw drops entry and continues to next key`() async throws { + func `isSyncingContacts defers handler until cleared`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - let keyFail = makePublicKey(seed: 0x28) - let keyOK = makePublicKey(seed: 0x29) - await session.setGetContactError(AdvertisementServiceTestError.getContactFailed, for: keyFail) - await session.setStubbedContact(makeMeshContact(publicKey: keyOK, name: "OK"), for: keyOK) + let key = makePublicKey(seed: 0x16) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Deferred") + ) await service.setSyncingContacts(true) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keyFail)) - await session.yieldEvent(.advertisement(publicKey: keyOK)) + await session.yieldEvent(.advertisement(publicKey: key)) - // Barrier must return even when one key throws. - await service.setSyncingContacts(false) + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 0) - let okSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keyOK)) != nil - } - let failSaved = try await store.fetchContact(radioID: radioID, publicKey: keyFail) + await service.setSyncingContacts(false) + let ran = await waitUntil { await recorder.callCount >= 1 } await service.stopEventMonitoring() - #expect(okSaved) - #expect(failSaved == nil) + #expect(ran) } + // MARK: - Teardown mid-handler + @Test - func `all keys throw ends pass without spin or residual contacts`() async throws { + func `teardown mid-handler prevents reconcile writes`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let keys = [makePublicKey(seed: 0x2A), makePublicKey(seed: 0x2B), makePublicKey(seed: 0x2C)] - for key in keys { - await session.setGetContactError(AdvertisementServiceTestError.getContactFailed, for: key) + let key = makePublicKey(seed: 0x27) + let hold = HandlerHold() + + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // Persist as a successful sync would. + _ = try? await store.saveContact( + radioID: radioID, + from: makeContactFrame(publicKey: key, name: "Late") + ) + return .synced } - await service.setSyncingContacts(true) await startMonitoring(service, session: session) - for key in keys { - await session.yieldEvent(.advertisement(publicKey: key)) - } - - let barrierState = BarrierFlag() - let barrierTask = Task { - await service.setSyncingContacts(false) - await barrierState.markDone() - } + await session.yieldEvent(.advertisement(publicKey: key)) - let barrierReturned = await waitUntil(timeout: .seconds(2)) { - await barrierState.isDone - } - #expect(barrierReturned, "barrier must return when every key throws") - _ = await barrierTask.result + let entered = await waitUntil { await hold.isWaiting } + #expect(entered) - for key in keys { - let count = await session.getContactPublicKeys.filter { $0 == key }.count - #expect(count == 1, "each throwing key fetched once") - let contact = try await store.fetchContact(radioID: radioID, publicKey: key) - #expect(contact == nil) - } await service.stopEventMonitoring() + await hold.release() + + // Settle: Contact may have been written by the held handler body, but + // reconcile must not create a Discover row after teardown. + try? await Task.sleep(for: .milliseconds(80)) + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty, "reconcile must not land after stopEventMonitoring") } - // MARK: - Bounded barrier + // MARK: - Escalation @Test - func `live enqueue after pass starts does not extend barrier`() async throws { + func `unknown key missing after success escalates to fullRefetch once`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - - let keySnap = makePublicKey(seed: 0x3A) - let keyLate = makePublicKey(seed: 0x3B) - await session.setStubbedContact(makeMeshContact(publicKey: keySnap, name: "Snap"), for: keySnap) - await session.setStubbedContact(makeMeshContact(publicKey: keyLate, name: "Late"), for: keyLate) - await session.holdNextGetContact(for: keySnap) - - await service.setSyncingContacts(true) + let recorder = HandlerRecorder(store: store, radioID: radioID) + // Incremental success (no row) → escalate; fullRefetch fails → flag restored; + // next call still fullRefetch; final fullRefetch success drops still-missing key. + await recorder.enqueueResult(.synced) + await recorder.enqueueResult(.failed) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x38) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keySnap)) - - // Start barrier (pass snapshots keySnap). - let barrierState = BarrierFlag() - let barrierTask = Task { - await service.setSyncingContacts(false) - await barrierState.markDone() - } - - let held = await waitUntil { await session.isGetContactHeld(for: keySnap) } - #expect(held) - - // Live enqueue after pass starts. - await session.yieldEvent(.advertisement(publicKey: keyLate)) - await session.releaseGetContact(for: keySnap) - - // Barrier must return without waiting for keyLate's fetch to complete the pass. - let barrierReturned = await waitUntil(timeout: .seconds(2)) { - await barrierState.isDone - } - #expect(barrierReturned, "barrier must not be extended by mid-pass enqueues") - _ = await barrierTask.result + await session.yieldEvent(.advertisement(publicKey: key)) - // Late key is still drained by the same worker afterwards. - let lateSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keyLate)) != nil - } + let threeCalls = await waitUntil { await recorder.callCount >= 3 } await service.stopEventMonitoring() - #expect(lateSaved) + #expect(threeCalls) + + let flags = await recorder.fullRefetchFlags + #expect(flags.count == 3) + #expect(flags[0] == false, "first pass is incremental") + #expect(flags[1] == true, "escalated full refetch") + #expect(flags[2] == true, "failed full refetch must restore escalate flag") + // Still-missing key is dropped after escalated success — no fourth call. + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 3) } + // MARK: - Nil handler keeps pending + @Test - func `late barrier during subsequent pass does not await further live enqueues`() async throws { + func `nil handler leaves keys pending for later install`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) - let keySnap = makePublicKey(seed: 0x3C) - let keyLate = makePublicKey(seed: 0x3D) - let keyExtra = makePublicKey(seed: 0x3E) - await session.setStubbedContact(makeMeshContact(publicKey: keySnap, name: "Snap"), for: keySnap) - await session.setStubbedContact(makeMeshContact(publicKey: keyLate, name: "Late"), for: keyLate) - await session.setStubbedContact(makeMeshContact(publicKey: keyExtra, name: "Extra"), for: keyExtra) - await session.holdNextGetContact(for: keySnap) + let key = makePublicKey(seed: 0x61) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Pending") + ) - await service.setSyncingContacts(true) + // No handler yet — schedule runs, finds nil, leaves keys pending. await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keySnap)) - - // First barrier: awaits snapshotted pass over keySnap only. - let firstBarrier = BarrierFlag() - let firstTask = Task { - await service.setSyncingContacts(false) - await firstBarrier.markDone() - } - - let snapHeld = await waitUntil { await session.isGetContactHeld(for: keySnap) } - #expect(snapHeld) - - // Late key arrives during the first pass; hold so the next pass parks on it. - await session.holdNextGetContact(for: keyLate) - await session.yieldEvent(.advertisement(publicKey: keyLate)) - await session.releaseGetContact(for: keySnap) + await session.yieldEvent(.advertisement(publicKey: key)) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 0) - let firstReturned = await waitUntil(timeout: .seconds(2)) { await firstBarrier.isDone } - #expect(firstReturned, "first barrier returns at its pass boundary") - _ = await firstTask.result + await installHandler(service, recorder: recorder) + let ran = await waitUntil { await recorder.callCount >= 1 } + await service.stopEventMonitoring() + #expect(ran, "installing a handler must re-arm pending keys") + } - let lateHeld = await waitUntil { await session.isGetContactHeld(for: keyLate) } - #expect(lateHeld, "late key is being drained in a subsequent snapshotted pass") + // MARK: - Min interval - // Late barrier joins mid-pass. Extra keys enqueued during this pass must not - // extend it (no drain-until-empty under live traffic). - let lateBarrier = BarrierFlag() - let lateTask = Task { - await service.setSyncingContacts(false) - await lateBarrier.markDone() - } + @Test + func `min interval delays second delta sync after success`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService( + session: session, + store: store, + advertSyncDebounce: .zero, + advertSyncMinInterval: .milliseconds(150) + ) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - await session.holdNextGetContact(for: keyExtra) - await session.yieldEvent(.advertisement(publicKey: keyExtra)) - await session.releaseGetContact(for: keyLate) + let keyA = makePublicKey(seed: 0x71) + let keyB = makePublicKey(seed: 0x72) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: keyA, name: "A") + ) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: keyB, name: "B") + ) - let lateReturned = await waitUntil(timeout: .seconds(2)) { await lateBarrier.isDone } - #expect(lateReturned, "late barrier returns at the end of its snapshotted pass") - _ = await lateTask.result + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: keyA)) + let first = await waitUntil { await recorder.callCount >= 1 } + #expect(first) - // Extra is next-pass work: not saved when the late barrier returns. - let extraBeforeRelease = try await store.fetchContact(radioID: radioID, publicKey: keyExtra) - #expect(extraBeforeRelease == nil, "live enqueue mid-pass must not block the barrier") + await session.yieldEvent(.advertisement(publicKey: keyB)) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 1, "second pass must wait for min interval") - await session.releaseGetContact(for: keyExtra) - let extraSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keyExtra)) != nil + let second = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount >= 2 } await service.stopEventMonitoring() - #expect(extraSaved) + #expect(second) } - // MARK: - Lost-wakeup handshake + // MARK: - 0x8F drops pending key - /// Covers enqueue after an idle worker exit (starts a new worker). Production - /// keeps empty-queue observation and generation-matched ref clear in one - /// actor region with no await between them. @Test - func `enqueue after worker exit still fetches`() async throws { + func `contactDeleted removes pending key before sync runs`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x49) + // Known contact so .contactUpdated after touch proves the advert was handled + // before 0x8F (event yields are not awaited through the drain loop). + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Doomed") + ) - let keyFirst = makePublicKey(seed: 0x4C) - let keySecond = makePublicKey(seed: 0x4D) - await session.setStubbedContact(makeMeshContact(publicKey: keyFirst, name: "First"), for: keyFirst) - await session.setStubbedContact(makeMeshContact(publicKey: keySecond, name: "Second"), for: keySecond) - await session.holdNextGetContact(for: keyFirst) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + // Defer delta sync so 0x8F can clear the pending map before drain. + await service.setSyncingContacts(true) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keyFirst)) + await session.yieldEvent(.advertisement(publicKey: key)) + let advertHandled = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(advertHandled) - let held = await waitUntil { await session.isGetContactHeld(for: keyFirst) } - #expect(held) + await session.yieldEvent(.contactDeleted(publicKey: key)) + let deleted = await waitUntil { + await (try? store.fetchContact(radioID: radioID, publicKey: key)) == nil + } + #expect(deleted) - // Release first so worker can finish; immediately enqueue second. - await session.releaseGetContact(for: keyFirst) - // Small yield so first fetch can complete, then enqueue near exit. - try? await Task.sleep(for: .milliseconds(20)) - await session.yieldEvent(.advertisement(publicKey: keySecond)) + await service.setSyncingContacts(false) + + // Pending key was removed → setSyncingContacts(false) does not re-arm. + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 0) + #expect(await counter.newContactCount == 0) - let secondSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keySecond)) != nil - } await service.stopEventMonitoring() - #expect(secondSaved, "key enqueued at worker exit must not strand") + service.finishEvents() + _ = await listener.result } - // MARK: - Dedup and nil drop - @Test - func `duplicate adverts for same key perform one fetch`() async throws { + func `reconcile skips deleted contact after mid-handler 0x8F`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let key = makePublicKey(seed: 0x5E) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Dup"), for: key) - await session.holdNextGetContact(for: key) + let key = makePublicKey(seed: 0x4A) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + return .synced + } await startMonitoring(service, session: session) await session.yieldEvent(.advertisement(publicKey: key)) - await session.yieldEvent(.advertisement(publicKey: key)) - await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) - await session.releaseGetContact(for: key) + // Contact never persisted; 0x8F during handler; reconcile sees nil row. + await session.yieldEvent(.contactDeleted(publicKey: key)) + await hold.release() + + let updated = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(updated) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await counter.newContactCount == 0) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } - let fetchCount = await session.getContactPublicKeys.filter { $0 == key }.count await service.stopEventMonitoring() - #expect(saved) - #expect(fetchCount == 1) + service.finishEvents() + _ = await listener.result } @Test - func `getContact nil drops the entry`() async throws { + func `contactDeleted during commit rolls back the resurrected contact`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let key = makePublicKey(seed: 0x6F) - // Explicit nil stub (no contact on device). - await session.setStubbedContact(nil, for: key) - - await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) - - // Wait for worker to process. - let fetched = await waitUntil { - await session.getContactPublicKeys.contains(key) + let key = makePublicKey(seed: 0x4B) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // The batch commit re-saves a row the radio deleted while it was in flight. + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + return .synced + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + // Let the 0x8F handler record the key before the commit returns. + try? await Task.sleep(for: .milliseconds(40)) + await hold.release() + + let synced = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(synced) + try? await Task.sleep(for: .milliseconds(60)) + + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact == nil, "a row re-saved after the radio deleted it must be rolled back") + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty, "a rolled-back contact must leave no Discover row") + #expect(await counter.newContactCount == 0) + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + } + + @Test + func `rollback keeps contact the radio deleted then re-added mid-round`() async throws { + // Overwrite-oldest can free a slot then auto-add the same key again inside + // one delta round. Rollback must not treat that re-synced row as a stale + // batch resurrection: the re-advert clears the 0x8F tombstone. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let advertKey = makePublicKey(seed: 0x93) + let deletedKey = makePublicKey(seed: 0x94) + _ = try await store.saveContact( + radioID: radioID, + from: makeContactFrame(publicKey: deletedKey, name: "SlotVictim") + ) + + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // getContacts after the radio re-added the key re-creates the local row. + _ = try? await store.saveContact( + radioID: radioID, + from: makeContactFrame(publicKey: deletedKey, name: "ReAdded") + ) + return .synced + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: advertKey)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: deletedKey)) + let deleted = await waitUntil { + let gone = await (try? store.fetchContact(radioID: radioID, publicKey: deletedKey)) == nil + let tracked = await service.contactsDeletedDuringSync.contains(deletedKey) + return gone && tracked + } + #expect(deleted) + + // Re-advert: radio auto-added the contact again mid-round. + await session.yieldEvent(.advertisement(publicKey: deletedKey)) + let reAdvertPending = await waitUntil { + let pending = await service.pendingAdvertKeys.contains(deletedKey) + let cleared = await !service.contactsDeletedDuringSync.contains(deletedKey) + return pending && cleared + } + #expect(reAdvertPending, "re-advert must clear the mid-round delete tombstone") + + await hold.release() + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) + try? await Task.sleep(for: .milliseconds(40)) + + let contact = try await store.fetchContact(radioID: radioID, publicKey: deletedKey) + #expect(contact != nil, "a contact the radio re-added mid-round must survive rollback") + #expect(contact?.name == "ReAdded") + #expect( + await !service.contactsDeletedDuringSync.contains(deletedKey), + "tombstone must stay clear so reconcile does not skip the re-added key" + ) + + await service.stopEventMonitoring() + } + + @Test + func `contactDeleted during failed commit rolls back and does not re-queue`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x4C) + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // An early batch committed the row before the sync failed. + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + return .failed + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + // Let the 0x8F handler record the key before the commit returns. + try? await Task.sleep(for: .milliseconds(40)) + await hold.release() + + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) + + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact == nil, "a row committed by a failed sync must still be rolled back") + #expect( + await !service.pendingAdvertKeys.contains(key), + "a radio-deleted key must not re-queue for a fetch the radio cannot answer" + ) + + await service.stopEventMonitoring() + } + + @Test + func `rollback still runs when teardown cancels the sync mid-commit`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x4D) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Known") + ) + + let hold = HandlerHold() + let marker = CommitMarker() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // The batch commit re-saves a row the radio deleted while it was in flight. + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + await marker.markCommitted() + return .synced + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + let deleted = await waitUntil { + await (try? store.fetchContact(radioID: radioID, publicKey: key)) == nil + } + #expect(deleted) + + // Teardown cancels the round and clears the handler while the commit runs. + await service.stopEventMonitoring() + await hold.release() + + let committed = await waitUntil { await marker.committed } + #expect(committed) + + let rolledBack = await waitUntil { + await (try? store.fetchContact(radioID: radioID, publicKey: key)) == nil + } + #expect(rolledBack, "a commit landing after teardown must still be rolled back") + } + + @Test + func `contact deleted after the commit returns stays tracked for the round`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let advertKey = makePublicKey(seed: 0x4E) + let deletedKey = makePublicKey(seed: 0x4F) + await recorder.enqueuePersist(makeContactFrame(publicKey: advertKey, name: "Synced")) + await installHandler(service, recorder: recorder) + + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: deletedKey, name: "Doomed") + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: advertKey)) + let committed = await waitUntil { await recorder.callCount >= 1 } + #expect(committed) + + await session.yieldEvent(.contactDeleted(publicKey: deletedKey)) + let tracked = await waitUntil { + await service.contactsDeletedDuringSync.contains(deletedKey) + } + await service.stopEventMonitoring() + #expect(tracked, "a delete outside the commit must still be tracked for the round") + } + + @Test + func `reconcile skips a contact the radio deleted this round`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x50) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Doomed") + ) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + // No handler installed, so no round drains the recorded delete before reconcile. + await startMonitoring(service, session: session) + await session.yieldEvent(.contactDeleted(publicKey: key)) + let tracked = await waitUntil { + await service.contactsDeletedDuringSync.contains(key) + } + #expect(tracked) + + // A batch commit re-saved the row the radio dropped. + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + await service.reconcile([key], insertedKeys: [key]) + + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty, "a deleted key must not gain a Discover row") + try? await Task.sleep(for: .milliseconds(30)) + #expect(await counter.newContactCount == 0, "a deleted key must not be announced") + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + } + + @Test + func `rolled back key does not escalate to a full refetch`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x52) + let hold = HandlerHold() + await service.setDeltaSyncHandler { fullRefetch in + let isFirst = await recorder.callCount == 0 + let result = await recorder.handle(fullRefetch: fullRefetch) + if isFirst { + await hold.waitUntilReleased() + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + } + return result } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + let tracked = await waitUntil { + await service.contactsDeletedDuringSync.contains(key) + } + #expect(tracked) + await hold.release() + + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + + #expect(await recorder.fullRefetchFlags == [false], "a rolled-back key must not refetch") + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact == nil) + } + + // MARK: - notReady outcome + + @Test + func `notReady outcome drops drained keys without reschedule or budget spend`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await recorder.enqueueResult(.notReady) + // A second result would only run if notReady incorrectly re-armed. + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x57) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "NotReady") + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + + #expect(await service.pendingAdvertKeys.isEmpty, "notReady must drop drained advert keys") + #expect(await recorder.callCount == 1, "notReady must not schedule another round") + #expect(await service.consecutiveDeltaSyncFailures == 0) + #expect(await service.lastDeltaSyncEnd == nil, "notReady must not stamp lastDeltaSyncEnd") + } + + // MARK: - Failure cap + + @Test + func `repeated failures stop the retry loop`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + for _ in 0..<(cap + 3) { + await recorder.enqueueResult(.failed) + } + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x53) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Flaky") + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let capped = await waitUntil { await recorder.callCount >= cap } + #expect(capped) + try? await Task.sleep(for: .milliseconds(120)) + await service.stopEventMonitoring() + + #expect(await recorder.callCount == cap, "retries must stop at the failure cap") + #expect(await service.pendingAdvertKeys.isEmpty, "the capped round drops its drained keys") + } + + @Test + func `busy rounds keep their keys past the failure cap`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + for _ in 0..<(cap + 2) { + await recorder.enqueueResult(.busy) + } + + // The key has no local row, so only the round that carries it can announce it. + let key = makePublicKey(seed: 0x54) + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "Late")) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let discovered = await waitUntil { await counter.newContactCount >= 1 } + #expect(discovered, "the drained key must survive collisions and reach the round that syncs it") + #expect( + await recorder.callCount > cap, + "a claim collision never reached the radio, so it must not spend the failure budget" + ) + + await service.stopEventMonitoring() + listener.cancel() + } + + // MARK: - Path-only sync + + @Test + func `pathUpdate deferred by contact sync still runs`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await service.setSyncingContacts(true) + await startMonitoring(service, session: session) + await session.yieldEvent(.pathUpdate(publicKey: makePublicKey(seed: 0x63))) + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 0) + + await service.setSyncingContacts(false) + let ran = await waitUntil { await recorder.callCount >= 1 } + await service.stopEventMonitoring() + #expect(ran, "a path update that fires while syncing must re-arm the delta sync") + } + + // MARK: - Contact-row newness gates the new-contact notification + + @Test + func `re-created contact with a surviving Discover row notifies again`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x74) + let frame = makeContactFrame(publicKey: key, name: "Returning") + // Deleting a contact leaves its Discover row. A re-advert for a missing + // Contact row is absent from the pre-round snapshot, so after the handler + // inserts the Contact it is in insertedKeys and notifies again. + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + await recorder.enqueuePersist(frame) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let discovered = await waitUntil { await counter.newContactCount >= 1 } + #expect(discovered, "a re-created Contact row must notify even when its Discover row survived") + try? await Task.sleep(for: .milliseconds(40)) + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact != nil, "the handler must have persisted the contact") + #expect(await counter.newContactCount == 1, "one insert must not notify twice") + } + + // MARK: - Store errors + + @Test + func `advert retries touch once then drops key when both fail`() async { + // Empty-round guard drops rounds with no recorded keys. A failed touch + // cannot invent known/unknown, so record nothing and retry once; the + // tradeoff is a permanently-failing store drops the advert until the next + // successful touch. + let store = MockPersistenceStore() + await store.setStubbedTouchContactHeardError(AdvertisementServiceTestError.storeUnavailable) + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: makePublicKey(seed: 0x85))) + + let retried = await waitUntil { await store.touchContactHeardCalls.count >= 2 } + #expect(retried, "touch must be retried once before giving up") + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + #expect(await recorder.callCount == 0, "empty-round guard must skip when no key was recorded") + #expect(await store.touchContactHeardCalls.count == 2) + } + + @Test + func `capped failure round restores pathSyncPending`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + for _ in 0..= cap } + #expect(capped) + try? await Task.sleep(for: .milliseconds(80)) + // Cap drops drained keys but must restore pathSyncPending so a path update + // is not silently lost. The next advert or path event re-arms. + #expect(await service.pathSyncPending) + #expect(await service.pendingAdvertKeys.isEmpty) + await service.stopEventMonitoring() + } + + @Test + func `capped failure round restores escalateToFullRefetch`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + // First success with no local row escalates; then fail to the cap so the + // final drop restores escalateToFullRefetch from the drained fullRefetch flag. + await recorder.enqueueResult(.synced) + for _ in 0..= 1 + cap + } + #expect(capped) + try? await Task.sleep(for: .milliseconds(80)) + #expect( + await service.escalateToFullRefetch, + "cap must restore escalateToFullRefetch when the drained round was a full refetch" + ) + #expect(await service.pendingAdvertKeys.isEmpty) + await service.stopEventMonitoring() + } + + @Test + func `fresh advert during the capped failing round re-arms and drains`() async throws { + // An 0x80 that lands while the final failing round is awaited already spent + // its one no-op scheduleDeltaSync (the task was still set). The cap must + // detect that fresh key and re-arm, not strand it until an unrelated event. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + + let firstKey = makePublicKey(seed: 0x71) + let freshKey = makePublicKey(seed: 0x72) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: firstKey, name: "First") + ) + + let injector = CapRoundInjector(service: service, freshKey: freshKey, cap: cap) + await service.setDeltaSyncHandler { _ in await injector.handle() } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: firstKey)) + + let rearmed = await waitUntil(timeout: .seconds(3)) { await injector.calls > cap } + #expect(rearmed, "a fresh advert during the capped round must re-arm the sync") + let drained = await waitUntil { await service.pendingAdvertKeys.isEmpty } + #expect(drained, "the re-armed round drains the fresh advert key") + + await service.stopEventMonitoring() + } + + @Test + func `setSyncingContacts false re-arms owed full refetch with empty pending keys`() async throws { + // hasPendingDeltaSyncWork and the empty-round guard must agree: an owed + // escalateToFullRefetch alone is work. After connect/manual sync toggles + // isSyncingContacts, re-arm must not require pendingAdvertKeys. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + await recorder.enqueueResult(.synced) + for _ in 0..= 1 + cap + } + #expect(capped) + let escalated = await waitUntil { + let escalate = await service.escalateToFullRefetch + let emptyKeys = await service.pendingAdvertKeys.isEmpty + let idle = await service.deltaSyncTask == nil + return escalate && emptyKeys && idle + } + #expect(escalated) + #expect(await !service.pathSyncPending) + + let callsBeforeRearm = await recorder.callCount + await service.setSyncingContacts(true) + await service.setSyncingContacts(false) + + let rearmed = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount > callsBeforeRearm + } + await service.stopEventMonitoring() + #expect(rearmed, "owed full refetch must re-arm when contact sync ends") + #expect( + await recorder.fullRefetchFlags.last == true, + "re-armed round must run as prune-free full refetch" + ) + } + + @Test + func `setDeltaSyncHandler re-arms owed full refetch with empty pending keys`() async throws { + // Same drift as setSyncingContacts: handler rewiring after a failed initial + // sync must re-arm when only escalateToFullRefetch remains. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + await recorder.enqueueResult(.synced) + for _ in 0..= 1 + cap + } + #expect(capped) + let escalated = await waitUntil { + let escalate = await service.escalateToFullRefetch + let emptyKeys = await service.pendingAdvertKeys.isEmpty + let idle = await service.deltaSyncTask == nil + return escalate && emptyKeys && idle + } + #expect(escalated) + + let callsBeforeRearm = await recorder.callCount + await service.setDeltaSyncHandler(nil) + await installHandler(service, recorder: recorder) + + let rearmed = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount > callsBeforeRearm + } + await service.stopEventMonitoring() + #expect(rearmed, "reinstalling the handler must re-arm an owed full refetch") + #expect(await recorder.fullRefetchFlags.last == true) + } + + @Test + func `finishRound with stale generation leaves the new task registered`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService( + session: session, + store: store, + advertSyncDebounce: .seconds(30), + advertSyncMinInterval: .zero + ) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x89) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Gen") + ) + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) - let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + let scheduled = await waitUntil { await service.deltaSyncTask != nil } + #expect(scheduled) + let liveGeneration = await service.deltaSyncGeneration + // A stale finishRound (cancelled prior round) must not wipe the live task. + await service.finishRound(generation: liveGeneration &- 1) + #expect(await service.deltaSyncTask != nil) + + // Matching generation clears as designed. + await service.finishRound(generation: liveGeneration) + #expect(await service.deltaSyncTask == nil) await service.stopEventMonitoring() - #expect(fetched) - #expect(contact == nil) } @Test - func `keys enqueued during sync fetch after setSyncingContacts false`() async throws { + func `setDeltaSyncHandler nil then reinstall still syncs pending keys`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) - let key = makePublicKey(seed: 0x70) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Deferred"), for: key) + let key = makePublicKey(seed: 0x8A) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Reinstall") + ) + await startMonitoring(service, session: session) + await service.setDeltaSyncHandler(nil) + await session.yieldEvent(.advertisement(publicKey: key)) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 0) + #expect(await service.pendingAdvertKeys.contains(key)) + + await installHandler(service, recorder: recorder) + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran, "reinstalling a handler must re-arm for already-pending keys") + await service.stopEventMonitoring() + } + + @Test + func `snapshot fetch failure does not invent new-contact notifications`() async throws { + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x8B) + // Known local contact: a failed snapshot must not treat it as inserted. + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Known") + ) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } - await service.setSyncingContacts(true) await startMonitoring(service, session: session) + // Fail only the snapshot read used by runDeltaSync (not touch / reconcile). + await store.setStubbedFetchContactPublicKeysError(AdvertisementServiceTestError.storeUnavailable) await session.yieldEvent(.advertisement(publicKey: key)) - // Still no fetch while syncing. - try? await Task.sleep(for: .milliseconds(50)) - let midFetchCount = await session.getContactPublicKeys.filter { $0 == key }.count - #expect(midFetchCount == 0) + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + try? await Task.sleep(for: .milliseconds(80)) - await service.setSyncingContacts(false) + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + + #expect( + await counter.newContactCount == 0, + "snapshot failure must suppress new-contact notifications (prefer miss over false notify)" + ) + } + + @Test + func `snapshot fetch failure still adopts orphaned DMs for drained keys`() async throws { + // A failed pre-round snapshot must not set adoption keys to empty. + // The handler still inserts the Contact; orphan DMs must link after the round. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x8C) + let prefix = Data(key.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "orphan before delta", + timestamp: 1_700_000_400, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "LateNode")) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + await startMonitoring(service, session: session) + await store.setStubbedFetchContactPublicKeysError(AdvertisementServiceTestError.storeUnavailable) + await session.yieldEvent(.advertisement(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + let linked = await waitUntil { + await store.messages[messageID]?.contactID != nil + } + await service.stopEventMonitoring() + #expect(linked, "snapshot failure must not permanently orphan DMs already received") + + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + #expect(await store.messages[messageID]?.contactID == contact.id) + #expect(contact.unreadCount == 1) + } + + @Test + func `adopting orphaned DMs emits conversationsChanged`() async throws { + // Adoption stamps lastMessageDate, which creates a conversation row. + // The mounted chat list reloads on conversationsVersion, not contactsVersion, + // so the service must emit conversationsChanged when any message is linked. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x8E) + let prefix = Data(key.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "orphan needs conversation signal", + timestamp: 1_700_000_500, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "AdoptedDM")) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + let linked = await waitUntil { + await store.messages[messageID]?.contactID != nil + } + #expect(linked) + let signaled = await waitUntil { await counter.conversationsChangedCount >= 1 } + #expect(signaled, "adoption must emit conversationsChanged so the chat list reloads") + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + + #expect(await counter.conversationsChangedCount >= 1) + #expect(await counter.contactUpdatedCount >= 1, "contactUpdated must still fire for Discover") + } + + @Test + func `adopting orphaned DMs emits orphanDirectMessagesAdopted with the contact`() async throws { + // The adopted DM never notified at receipt (no contact then). The service + // must hand the adopting contact to the NotificationService owner so the + // banner and badge fire; a bare conversationsChanged does neither. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x9E) + let prefix = Data(key.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "orphan awaiting notification", + timestamp: 1_700_000_600, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "AdoptedDM")) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let linked = await waitUntil { await store.messages[messageID]?.contactID != nil } + #expect(linked) + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let announced = await waitUntil { await counter.adoptedContactIDs.contains(contact.id) } + #expect(announced, "adoption must announce the contact so the banner and badge fire") + await service.stopEventMonitoring() - #expect(saved) + service.finishEvents() + _ = await listener.result } @Test - func `successful advert save emits newContactDiscovered once`() async throws { + func `delta round with no adoption does not emit conversationsChanged`() async throws { + // A successful round that links zero orphan DMs must not force a chat-list + // reload. contactUpdated still fires for Discover. let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) - let key = makePublicKey(seed: 0x81) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Signal"), for: key) + let key = makePublicKey(seed: 0x8F) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "NoOrphans") + ) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) let counter = EventCounter() let events = service.events() let listener = Task { for await event in events { - if case .newContactDiscovered = event { - await counter.increment() - } + await counter.note(event) } } await startMonitoring(service, session: session) await session.yieldEvent(.advertisement(publicKey: key)) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + let updated = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(updated) + // Yield to the event loop so a wrongly unconditional emit would be observed. + try? await Task.sleep(for: .milliseconds(80)) + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + + #expect( + await counter.conversationsChangedCount == 0, + "rounds that adopt nothing must not emit conversationsChanged" + ) + } + + @Test + func `materializeContactForPendingAdvert creates contact for unique pending key`() async throws { + // A DM in the debounce window needs a Contact row immediately so the live + // notify path can run. Materialize from the pending 0x80 key. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x8D) + let mesh = makeMeshContact(publicKey: key, name: "DebounceNode") + await session.setStubbedContact(mesh, for: key) + + // No delta handler: advert records the pending key and never inserts a row. + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let pending = await waitUntil { await service.pendingAdvertKeys.contains(key) } + #expect(pending) + #expect(try await store.fetchContact(radioID: radioID, publicKey: key) == nil) + + let prefix = Data(key.prefix(6)) + let contact = try #require( + await service.materializeContactForPendingAdvert(matchingPrefix: prefix, radioID: radioID) + ) + #expect(contact.publicKey == key) + #expect(contact.name == "DebounceNode") + #expect(await service.pendingAdvertKeys.contains(key), "key must stay pending for delta reconcile") + #expect(await session.getContactPublicKeys.contains(key)) + + // Prefix lookup is what MessagePollingService uses for the next DM hop. + let byPrefix = try await store.fetchContact(radioID: radioID, publicKeyPrefix: prefix) + #expect(byPrefix?.id == contact.id) + await service.stopEventMonitoring() + } + + @Test + func `materializeContactForPendingAdvert returns nil without pending match`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let key = makePublicKey(seed: 0x8E) + await session.setStubbedContact(makeMeshContact(publicKey: key, name: "NoPending"), for: key) + + await startMonitoring(service, session: session) + let result = await service.materializeContactForPendingAdvert( + matchingPrefix: Data(key.prefix(6)), + radioID: radioID + ) + #expect(result == nil) + #expect(await session.getContactPublicKeys.isEmpty) + await service.stopEventMonitoring() + } + + @Test + func `materializeContactForPendingAdvert returns nil on multi-match prefix`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + // Two 32-byte keys that share the same 1-byte prefix used for the query. + var keyA = makePublicKey(seed: 0x8F) + var keyB = makePublicKey(seed: 0x90) + keyA[0] = 0xAB + keyB[0] = 0xAB + #expect(keyA != keyB) + + await service.recordPendingAdvertKey(keyA) + await service.recordPendingAdvertKey(keyB) + let result = await service.materializeContactForPendingAdvert( + matchingPrefix: Data([0xAB]), + radioID: radioID + ) + #expect(result == nil) + #expect(await session.getContactPublicKeys.isEmpty) + } + + @Test + func `empty schedule with no pending work does not call the handler`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await startMonitoring(service, session: session) + // Schedule without any pending keys/path: empty-round guard must no-op. + await service.scheduleDeltaSync() + try? await Task.sleep(for: .milliseconds(80)) + #expect(await recorder.callCount == 0) + await service.stopEventMonitoring() + } + + @Test + func `busy outcome does not stamp min-interval when backoff is non-zero`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + // Busy must not stamp lastDeltaSyncEnd; a non-zero min interval would block re-arm. + let service = makeService( + session: session, + store: store, + advertSyncDebounce: .zero, + advertSyncMinInterval: .seconds(30), + advertSyncBusyBackoff: .milliseconds(20) + ) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await recorder.enqueueResult(.busy) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x87) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Busy") + ) + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let second = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount >= 2 } - try? await Task.sleep(for: .milliseconds(50)) + await service.stopEventMonitoring() + #expect(second, "busy must re-arm on busy backoff, not the 30s min interval") + } + + @Test + func `touch failure does not announce a known contact as new`() async throws { + // A failed touch cannot tell known from unknown, so the key is not recorded + // after the one retry. Empty-round guard then skips the handler — prefer + // losing the round over inventing a new-contact notification. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x51) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "LongKnown") + ) + await store.setStubbedTouchContactHeardError(AdvertisementServiceTestError.storeUnavailable) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let retried = await waitUntil { await store.touchContactHeardCalls.count >= 2 } + #expect(retried) + try? await Task.sleep(for: .milliseconds(60)) + await service.stopEventMonitoring() service.finishEvents() _ = await listener.result - let newContactCount = await counter.count - #expect(saved) - #expect(newContactCount == 1, "exactly one UI-refresh signal on successful save") + #expect(await recorder.callCount == 0, "no key recorded means empty-round guard skips") + #expect( + await counter.newContactCount == 0, + "a store error must not record a known contact as unknown" + ) + } + + @Test + func `contact lookup failure does not escalate to full refetch`() async { + let store = MockPersistenceStore() + await store.setStubbedFetchContactError(AdvertisementServiceTestError.storeUnavailable) + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: makePublicKey(seed: 0x96))) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + + #expect(await recorder.callCount == 1, "a local read failure must not trigger a radio refetch") + #expect(await recorder.fullRefetchFlags == [false]) + } + + // MARK: - Prune safety + + @Test + func `fresh lastHeard protects contact with old lastModified from prune`() async throws { + let store = try await makeStore() + let oldStamp: UInt32 = 1_000_000 + let freshStamp = UInt32(Date().timeIntervalSince1970) + let key = makePublicKey(seed: 0x5A) + + try await store.saveContact(ContactDTO.testContact( + radioID: radioID, + publicKey: key, + name: "StaleRadio", + lastModified: oldStamp, + lastHeardTimestamp: freshStamp + )) + + // Production path: removeStaleNodes uses matchesStaleNodePrune on fetched DTOs. + let fetched = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let days = 30 + let cutoff = UInt32(Date().addingTimeInterval(-Double(days) * 86400).timeIntervalSince1970) + + #expect(fetched.lastModified < cutoff) + #expect(!fetched.matchesStaleNodePrune(cutoff: cutoff)) + + // Control: old lastHeard and lastModified both stale → would prune. + let staleKey = makePublicKey(seed: 0x5B) + try await store.saveContact(ContactDTO.testContact( + radioID: radioID, + publicKey: staleKey, + name: "TrulyStale", + lastModified: oldStamp, + lastHeardTimestamp: oldStamp + )) + let trulyStale = try #require(await store.fetchContact(radioID: radioID, publicKey: staleKey)) + #expect(trulyStale.matchesStaleNodePrune(cutoff: cutoff)) + } + + @Test + func `unknown advert contact stamped lastHeard survives stale-node prune`() async throws { + // A 0x80 for a key with no local row cannot touch lastHeard until the delta + // insert. Contact(radioID:from:) hardcodes lastHeard = 0; without a post-insert + // stamp, a stale radio lastModified makes matchesStaleNodePrune true. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x5C) + let oldStamp: UInt32 = 1_000_000 + let oldDate = Date(timeIntervalSince1970: TimeInterval(oldStamp)) + await recorder.enqueuePersist( + makeContactFrame( + publicKey: key, + name: "JustHeard", + lastAdvertTimestamp: oldStamp, + lastModified: oldStamp + ) + ) + await installHandler(service, recorder: recorder) + + let beforeAdvert = Date().addingTimeInterval(-1) + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let stamped = await waitUntil { + let contact = try? await store.fetchContact(radioID: radioID, publicKey: key) + return (contact?.lastHeardTimestamp ?? 0) > 0 + } + #expect(stamped, "post-delta stamp must set lastHeardTimestamp after insert") + + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let pruneDays = 30 + let secondsPerDay: TimeInterval = 86400 + let cutoff = UInt32( + Date().addingTimeInterval(-Double(pruneDays) * secondsPerDay).timeIntervalSince1970 + ) + + #expect(contact.lastModified == oldStamp) + #expect(contact.lastModified < cutoff) + #expect((contact.lastHeardTimestamp ?? 0) >= UInt32(beforeAdvert.timeIntervalSince1970)) + #expect(contact.recencyTimestamp >= UInt32(beforeAdvert.timeIntervalSince1970)) + #expect(!contact.matchesStaleNodePrune(cutoff: cutoff)) + + // The radio-sourced timestamps alone would still fall before the cutoff. + #expect(oldDate.timeIntervalSince1970 < Double(cutoff)) + + await service.stopEventMonitoring() + } + + @Test + func `favorite contact with stale recency does not match stale-node prune`() { + let oldStamp: UInt32 = 1_000_000 + let pruneDays = 30 + let secondsPerDay: TimeInterval = 86400 + let cutoff = UInt32( + Date().addingTimeInterval(-Double(pruneDays) * secondsPerDay).timeIntervalSince1970 + ) + let favorite = ContactDTO.testContact( + radioID: radioID, + publicKey: makePublicKey(seed: 0x5D), + name: "FavoriteStale", + lastModified: oldStamp, + lastHeardTimestamp: oldStamp, + isFavorite: true + ) + #expect(favorite.recencyTimestamp < cutoff) + #expect(!favorite.matchesStaleNodePrune(cutoff: cutoff)) + } + + // MARK: - Path discovery response lastHeard + + @Test + func `pathResponse stamps lastHeard and preserves radio lastModified`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0xA5) + let radioLastMod: UInt32 = 1_700_000_100 + _ = try await store.saveContact( + radioID: radioID, + from: makeContactFrame( + publicKey: key, + name: "PathPeer", + lastModified: radioLastMod + ) + ) + + await startMonitoring(service, session: session) + + let pathInfo = PathInfo( + publicKeyPrefix: Data(key.prefix(6)), + outPathLength: 0, + outPath: Data(), + inPathLength: 0, + inPath: Data() + ) + await session.yieldEvent(.pathResponse(pathInfo)) + + let stamped = await waitUntil { + guard let contact = try? await store.fetchContact(radioID: radioID, publicKey: key) else { + return false + } + return (contact.lastHeardTimestamp ?? 0) > 0 + } + #expect(stamped) + + let updated = try #require( + await store.fetchContact(radioID: radioID, publicKey: key) + ) + #expect(updated.lastModified == radioLastMod) + #expect((updated.lastHeardTimestamp ?? 0) > 0) + + await service.stopEventMonitoring() + } + + @Test + func `pathUpdate does not stamp lastHeard`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0xA6) + _ = try await store.saveContact( + radioID: radioID, + from: makeContactFrame( + publicKey: key, + name: "PathUpdatePeer", + lastModified: 1_700_000_100 + ) + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.pathUpdate(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + + let updated = try #require( + await store.fetchContact(radioID: radioID, publicKey: key) + ) + #expect((updated.lastHeardTimestamp ?? 0) == 0) + + await service.stopEventMonitoring() } } // MARK: - Concurrency helpers -private actor BarrierFlag { - private(set) var isDone = false - func markDone() { - isDone = true +private actor CommitMarker { + private(set) var committed = false + + func markCommitted() { + committed = true } } -private actor EventCounter { - private(set) var count = 0 - func increment() { - count += 1 +private actor HandlerHold { + private var continuation: CheckedContinuation? + private(set) var isWaiting = false + /// Sticky: once released, later `waitUntilReleased` calls return immediately so a + /// follow-up delta round (re-arm / escalate) does not hang on a one-shot gate. + private var isReleased = false + + func waitUntilReleased() async { + if isReleased { return } + isWaiting = true + await withCheckedContinuation { (cont: CheckedContinuation) in + if isReleased { + cont.resume() + } else { + continuation = cont + } + } + isWaiting = false + } + + func release() { + isReleased = true + let cont = continuation + continuation = nil + cont?.resume() } } diff --git a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift index 994ee8d73..a9ea810c7 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift @@ -788,6 +788,7 @@ struct BackupIntegrationTests { latitude: existingContact.latitude, longitude: existingContact.longitude, lastModified: existingContact.lastModified, + lastHeardTimestamp: nil, nickname: "Field Ops", isBlocked: true, isMuted: true, @@ -901,6 +902,242 @@ struct BackupIntegrationTests { #expect(mergedCarol.avatarImageData == Data(repeating: 0x33, count: 8)) } + // MARK: - Test 11c: lastHeardTimestamp backup round-trip + + @Test + func `Contact lastHeardTimestamp survives encode decode export import and merge`() async throws { + let withHeard = ContactDTO.testContact(lastHeardTimestamp: 1_700_000_500) + let encoded = try JSONEncoder().encode(withHeard) + let decoded = try JSONDecoder().decode(ContactDTO.self, from: encoded) + #expect(decoded.lastHeardTimestamp == 1_700_000_500) + + var legacyJSON = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + legacyJSON.removeValue(forKey: "lastHeardTimestamp") + let legacyData = try JSONSerialization.data(withJSONObject: legacyJSON) + let legacyDecoded = try JSONDecoder().decode(ContactDTO.self, from: legacyData) + #expect(legacyDecoded.lastHeardTimestamp == nil) + + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let sourceContact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xF1, count: 32), + name: "Heard", + lastHeardTimestamp: 1_700_000_600 + ) + try await sourceStore.saveContact(sourceContact) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let importResult = try await service.importBackup(envelope: envelope, into: destStore) + #expect(importResult.contactsInserted == 1) + + let inserted = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: sourceContact.publicKey) + ) + #expect(inserted.lastHeardTimestamp == 1_700_000_600) + + // Legacy envelope (missing key) materializes as model 0. + let legacyContact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xF2, count: 32), + name: "Legacy", + lastHeardTimestamp: nil + ) + var legacyContactJSON = try #require( + try JSONSerialization.jsonObject(with: JSONEncoder().encode(legacyContact)) as? [String: Any] + ) + legacyContactJSON.removeValue(forKey: "lastHeardTimestamp") + let legacyContactData = try JSONSerialization.data(withJSONObject: legacyContactJSON) + let legacyContactDecoded = try JSONDecoder().decode(ContactDTO.self, from: legacyContactData) + try await destStore.saveContact(legacyContactDecoded) + let legacyRow = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: legacyContact.publicKey) + ) + #expect(legacyRow.lastHeardTimestamp == 0 || legacyRow.lastHeardTimestamp == nil) + + // Merge: local newer keeps local; backup newer adopts; future stamp clamps. + let mergeKey = Data(repeating: 0xF3, count: 32) + let localNewer = ContactDTO.testContact( + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: 1_700_000_900 + ) + try await destStore.saveContact(localNewer) + + let olderBackup = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: 1_700_000_100 + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [olderBackup] + ), + into: destStore + ) + let afterOlder = try #require(await destStore.fetchContact(radioID: radioID, publicKey: mergeKey)) + #expect(afterOlder.lastHeardTimestamp == 1_700_000_900) + + let newerBackup = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: 1_700_001_000 + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [newerBackup] + ), + into: destStore + ) + let afterNewer = try #require(await destStore.fetchContact(radioID: radioID, publicKey: mergeKey)) + #expect(afterNewer.lastHeardTimestamp == 1_700_001_000) + + let farFuture = UInt32(Date().timeIntervalSince1970) + 86400 + let futureBackup = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: farFuture + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [futureBackup] + ), + into: destStore + ) + let afterFuture = try #require(await destStore.fetchContact(radioID: radioID, publicKey: mergeKey)) + let now = UInt32(Date().timeIntervalSince1970) + let tolerance = UInt32(SyncCoordinator.timestampToleranceFuture) + let upper = now > UInt32.max - tolerance ? UInt32.max : now + tolerance + #expect(afterFuture.lastHeardTimestamp ?? 0 <= upper) + #expect(afterFuture.lastHeardTimestamp ?? 0 >= 1_700_001_000) + + // Insert-only far-future stamp clamps on first import (no local row). + let insertKey = Data(repeating: 0xF4, count: 32) + let insertFuture = UInt32(Date().timeIntervalSince1970) + 86400 + let insertBackup = ContactDTO.testContact( + radioID: radioID, + publicKey: insertKey, + name: "FutureInsert", + lastHeardTimestamp: insertFuture + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [insertBackup] + ), + into: destStore + ) + let insertedFuture = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: insertKey) + ) + let insertNow = UInt32(Date().timeIntervalSince1970) + let insertUpper = insertNow > UInt32.max - tolerance ? UInt32.max : insertNow + tolerance + #expect(insertedFuture.lastHeardTimestamp ?? 0 <= insertUpper) + #expect(insertedFuture.lastHeardTimestamp ?? 0 < insertFuture) + } + + // MARK: - Orphan DM adoption after reminted Device.id + + @Test + func `Orphan DM survives export import and adoption under reminted device id`() async throws { + let radioID = UUID() + let contactKey = Data(repeating: 0xAD, count: 32) + let prefix = Data(contactKey.prefix(6)) + let stamp = UInt32(1_700_000_700) + + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let sourceDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + try await sourceStore.saveDevice(sourceDevice) + + // Orphan DM stored before the contact row exists. + try await sourceStore.saveMessage( + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "pre-contact dm", + timestamp: stamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(stamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + let contact = ContactDTO.testContact( + radioID: radioID, publicKey: contactKey, name: "Adopted" + ) + try await sourceStore.saveContact(contact) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Fresh store: Device.id reminted, radioID and publicKey survive. + let destRadioID = UUID() + let destStore = try await PersistenceStore.createTestDataStore(radioID: destRadioID) + let destDevice = DeviceDTO.testDevice( + id: UUID(), + radioID: destRadioID, + publicKey: sourceDevice.publicKey + ) + try await destStore.saveDevice(destDevice) + + _ = try await service.importBackup(envelope: envelope, into: destStore) + + // After import, contact and orphan share the remapped radio partition. + let importedContacts = try await destStore.fetchContacts(radioID: destRadioID) + let imported = try #require(importedContacts.first { $0.publicKey == contactKey }) + let adopted = try await destStore.adoptOrphanedDirectMessages( + radioID: destRadioID, + contacts: [(id: imported.id, publicKey: imported.publicKey)] + ) + #expect(adopted[imported.id] == 1 || adopted.isEmpty) + // Idempotent second run. + let second = try await destStore.adoptOrphanedDirectMessages( + radioID: destRadioID, + contacts: [(id: imported.id, publicKey: imported.publicKey)] + ) + #expect(second.isEmpty) + + let messages = try await destStore.fetchMessages(contactID: imported.id, limit: 10, offset: 0) + // Either import remapped the orphan already, or adoption linked it. + let all = try await destStore.fetchAllMessages(radioID: destRadioID) + let linked = all.filter { $0.text == "pre-contact dm" } + #expect(linked.count == 1) + if let msg = linked.first { + #expect(msg.contactID == imported.id || messages.contains { $0.id == msg.id }) + } + } + // MARK: - Test 12: Merge import — channel metadata restored @Test @@ -2234,66 +2471,61 @@ struct BackupIntegrationTests { } @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) + func `Import on the process store updates rows already registered in that context`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + let publicKey = Data(repeating: 0x51, count: 32) + let importedDate = Date(timeIntervalSince1970: 1_700_000_000) - let backupRadioID = UUID() - let liveRadioID = UUID() - let backupDevicePublicKey = Data(repeating: 0xB0, count: 32) - let liveDevicePublicKey = Data(repeating: 0xC0, count: 32) + try await store.saveContact( + ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "Alice", + nickname: nil, + isBlocked: false, + unreadCount: 0 + ) + ) - let backupContact = ContactDTO.testContact( - radioID: backupRadioID, - publicKey: Data(repeating: 0xB1, count: 32), - name: "From backup" + // Warm registered objects the way a process-lifetime store does after + // live reads so later fetches see import-mutated fields. + let warmed = try #require( + await store.fetchContact(radioID: radioID, publicKey: publicKey) ) + #expect(warmed.nickname == nil) + #expect(warmed.isBlocked == false) + #expect(warmed.unreadCount == 0) + let envelope = AppBackupEnvelope.test( - devices: [ - DeviceDTO.testDevice( - id: backupRadioID, - radioID: backupRadioID, - publicKey: backupDevicePublicKey + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [ + ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "Alice", + nickname: "Field Ops", + isBlocked: true, + lastMessageDate: importedDate, + unreadCount: 7 ) - ], - 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" + let result = try await AppBackupService().importBackup( + envelope: envelope, + into: store ) + #expect(result.contactsSkipped == 1) + #expect(result.contactsMerged == 1) - 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) + let after = try #require( + await store.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(after.nickname == "Field Ops") + #expect(after.isBlocked == true) + #expect(after.unreadCount == 7) + #expect(after.lastMessageDate == importedDate) } // MARK: - Test 18: Export assigns content-based keys to nil-keyed messages @@ -3067,6 +3299,140 @@ struct BackupIntegrationTests { #expect(nilModel.regionScope == nil) } + // MARK: - Message.regionScopeMatches round-trip + + @Test + func `Message.regionScopeMatches 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 = nil + msg.regionScopeMatches = ["de-by", "de-hh"] + msg.deduplicationKey = "region-scope-matches-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 == nil) + #expect(restored.first?.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `MessageDTO Codable: regionScopeMatches set decodes round-trip`() throws { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Test") + dto.regionScopeMatches = ["de-by", "de-hh"] + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `Legacy MessageDTO envelope without regionScopeMatches decodes as empty`() 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: "regionScopeMatches") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.regionScopeMatches == []) + } + + @Test + func `Message(dto:) forwards regionScopeMatches verbatim through DTO to model`() { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") + dto.regionScopeMatches = ["de-by", "de-hh"] + let model = Message(dto: dto) + #expect(model.regionScopeMatches == ["de-by", "de-hh"]) + } + + // MARK: - failureSeen round-trip + + @Test + func `MessageDTO Codable: failureSeen true round-trips`() throws { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Failed") + dto.failureSeen = true + dto.status = .failed + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.failureSeen == true) + } + + @Test + func `Legacy MessageDTO envelope without failureSeen decodes as false`() 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: "failureSeen") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.failureSeen == false) + } + + @Test + func `Message(dto:) forwards failureSeen verbatim through DTO to model`() { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") + dto.failureSeen = true + let model = Message(dto: dto) + #expect(model.failureSeen == true) + } + + @Test + func `RoomMessageDTO Codable: failureSeen true round-trips`() throws { + let dto = RoomMessageDTO.testRoomMessage( + sessionID: UUID(), + isFromSelf: true, + status: .failed, + failureSeen: true + ) + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(RoomMessageDTO.self, from: encoded) + #expect(decoded.failureSeen == true) + } + + @Test + func `Legacy RoomMessageDTO envelope without failureSeen decodes as false`() throws { + let baseDTO = RoomMessageDTO.testRoomMessage(sessionID: UUID(), isFromSelf: true, status: .failed) + let encoded = try JSONEncoder().encode(baseDTO) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "failureSeen") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(RoomMessageDTO.self, from: stripped) + #expect(decoded.failureSeen == false) + } + + @Test + func `RoomMessage(dto:) forwards failureSeen verbatim through DTO to model`() { + let dto = RoomMessageDTO.testRoomMessage( + sessionID: UUID(), + isFromSelf: true, + status: .failed, + failureSeen: true + ) + let model = RoomMessage(dto: dto) + #expect(model.failureSeen == true) + } + // MARK: - MessageDTO.sortDate round-trip @Test diff --git a/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift index 0eea072da..0e13bc859 100644 --- a/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift @@ -89,6 +89,7 @@ struct BondLossPairingRecoveryTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -269,6 +270,7 @@ struct BondLossPairingRecoveryTests { await manager.clearPersistedConnection(for: deviceID) #expect(await sm.bondVerificationDate(for: deviceID) == nil) + await sm.appDidBecomeActive() await sm.primeBondClearAutoReconnecting(peripheral: peripheral) let recorder = BondClearDisconnectionRecorder() await sm.setDisconnectionHandler { id, error in diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift index 1f21dd815..ab8c24568 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift @@ -92,6 +92,7 @@ struct ConnectRadioIDResolutionTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift index a477f8661..b8cadf185 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift @@ -214,7 +214,11 @@ struct ConnectionManagerReconnectAbandonmentTests { // Guard-exit: intent no longer wants connection when the sleep completes. env.manager.setTestState(connectionIntent: .userDisconnected) - try await Task.sleep(for: .milliseconds(80)) + // After testWatchdogInitialDelay the watchdog Task resumes on MainActor. + // Wait until the natural-exit defer has niled reconnectionWatchdogTask. + try await waitUntil(timeout: .seconds(2), "watchdog should nil after natural exit") { + !env.manager.isReconnectionWatchdogRunning + } // Natural-exit defer must nil the finished Task (not isCancelled). #expect(!env.manager.isReconnectionWatchdogRunning) diff --git a/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift b/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift new file mode 100644 index 000000000..03440ece3 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift @@ -0,0 +1,131 @@ +import Foundation +@testable import MC1Services +import MeshCore +import SQLite3 +import SwiftData +import Testing + +private let discoveredNodeTable = "ZDISCOVEREDNODE" +private let contactTable = "ZCONTACT" +private let outPathLengthColumn = "ZOUTPATHLENGTH" +private let sqliteBusyTimeoutMilliseconds: Int32 = 5000 + +@Suite("Unsigned path length fetch") +struct DiscoveredNodeUnsignedFetchTests { + @Test + func `Backup export of a leftover Int8 flood sentinel (-1)`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("discovered-int8-\(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() + do { + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let store = PersistenceStore(modelContainer: container) + let frame = ContactFrame( + publicKey: Data(repeating: 0x44, count: 32), + type: .repeater, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: "LegacyFlood", + lastAdvertTimestamp: 1, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + } + + _ = try sqlite(storeURL, "UPDATE \(discoveredNodeTable) SET \(outPathLengthColumn) = -1;") + #expect(try sqlite(storeURL, "SELECT \(outPathLengthColumn) FROM \(discoveredNodeTable);") == "-1") + + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let snapshot = try await PersistenceStore(modelContainer: reopened).fetchBackupExportSnapshot() + let node = try #require(snapshot.discoveredNodes.first) + #expect(snapshot.discoveredNodes.count == 1) + #expect(node.outPathLength == PacketBuilder.floodPathSentinel) + } + + @Test + func `Backup export of a leftover Contact Int8 flood sentinel (-1)`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("contact-int8-\(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() + do { + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let store = PersistenceStore(modelContainer: container) + let frame = ContactFrame( + publicKey: Data(repeating: 0x55, count: 32), + type: .chat, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: "LegacyContact", + lastAdvertTimestamp: 1, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + _ = try await store.saveContact(radioID: radioID, from: frame) + } + + _ = try sqlite(storeURL, "UPDATE \(contactTable) SET \(outPathLengthColumn) = -1;") + #expect(try sqlite(storeURL, "SELECT \(outPathLengthColumn) FROM \(contactTable);") == "-1") + + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let snapshot = try await PersistenceStore(modelContainer: reopened).fetchBackupExportSnapshot() + let contact = try #require(snapshot.contacts.first) + #expect(snapshot.contacts.count == 1) + #expect(contact.outPathLength == PacketBuilder.floodPathSentinel) + } +} + +private func sqlite(_ storeURL: URL, _ sql: String) throws -> String { + var db: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + guard sqlite3_open_v2(storeURL.path, &db, flags, nil) == SQLITE_OK, let db else { + throw SQLiteProbeError(message: "open failed: \(storeURL.path)") + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, sqliteBusyTimeoutMilliseconds) + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw SQLiteProbeError(message: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(statement) } + + var rows: [String] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { break } + guard status == SQLITE_ROW else { + throw SQLiteProbeError(message: String(cString: sqlite3_errmsg(db))) + } + if let value = sqlite3_column_text(statement, 0) { + rows.append(String(cString: value)) + } + } + return rows.joined(separator: "\n") +} + +private struct SQLiteProbeError: Error { + let message: String +} diff --git a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift index 72904a893..d474a300e 100644 --- a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift @@ -1,3 +1,4 @@ +import Foundation @testable import MC1Services import Testing @@ -17,7 +18,27 @@ struct EnvInputsThemeTokenTests { isOffline: false, currentUserName: "Tester", themeID: themeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode + ) + } + + private func make(preferredLanguageCode: String) -> EnvInputs { + EnvInputs( + autoPlayGIFs: true, + showIncomingPath: true, + showIncomingHopCount: true, + showIncomingRegion: true, + showIncomingSendTime: true, + previewsEnabled: true, + isHighContrast: false, + isDark: false, + showMapPreviews: true, + isOffline: false, + currentUserName: "Tester", + themeID: EnvInputs.defaultThemeID, + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: preferredLanguageCode ) } @@ -27,8 +48,21 @@ struct EnvInputsThemeTokenTests { #expect(make(themeID: "default") == make(themeID: "default")) } + @Test + func `changing only preferredLanguageCode makes EnvInputs unequal`() { + #expect(make(preferredLanguageCode: "en") != make(preferredLanguageCode: "uk")) + #expect(make(preferredLanguageCode: "en") == make(preferredLanguageCode: "en")) + } + @Test func `EnvInputs.default carries the default theme id`() { #expect(EnvInputs.default.themeID == "default") } + + @Test + func `preferredLanguageCode from locale collapses region and script`() { + #expect(EnvInputs.preferredLanguageCode(from: Locale(identifier: "en-US")) == "en") + #expect(EnvInputs.preferredLanguageCode(from: Locale(identifier: "zh-Hans")) == "zh") + #expect(EnvInputs.preferredLanguageCode(from: Locale(identifier: "de-DE")) == "de") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift b/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift new file mode 100644 index 000000000..f34598a34 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift @@ -0,0 +1,354 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("Failed-send conversation keys") +struct FailedSendConversationKeysTests { + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + @Test + func `outgoing failed DM includes the contact id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: contactID, status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + #expect(keys.channelIDs.isEmpty) + #expect(keys.roomSessionIDs.isEmpty) + } + + @Test + func `incoming sent retrying pending sending and other radio DMs are absent`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let otherRadioID = UUID() + let incomingID = UUID() + let sentID = UUID() + let retryingID = UUID() + let pendingID = UUID() + let sendingID = UUID() + let otherRadioContactID = UUID() + + try await store.saveMessage( + .testDirectMessage( + radioID: radioID, + contactID: incomingID, + direction: .incoming, + status: .failed + ) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: sentID, status: .sent) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: retryingID, status: .retrying) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: pendingID, status: .pending) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: sendingID, status: .sending) + ) + try await store.saveMessage( + .testDirectMessage( + radioID: otherRadioID, + contactID: otherRadioContactID, + status: .failed + ) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs.isEmpty) + #expect(keys.channelIDs.isEmpty) + #expect(keys.roomSessionIDs.isEmpty) + } + + @Test + func `outgoing failed channel message includes that channel id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let channel = ChannelDTO.testChannel(radioID: radioID, index: 3) + try await store.saveChannel(channel) + try await store.saveMessage( + .testChannelMessage(radioID: radioID, channelIndex: channel.index, status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.channelIDs == [channel.id]) + #expect(keys.contactIDs.isEmpty) + #expect(keys.roomSessionIDs.isEmpty) + } + + @Test + func `failed channel on another radio with the same index is absent`() async throws { + let store = try await createTestStore() + let radioA = UUID() + let radioB = UUID() + let channelA = ChannelDTO.testChannel(radioID: radioA, index: 0) + let channelB = ChannelDTO.testChannel(radioID: radioB, index: 0) + try await store.saveChannel(channelA) + try await store.saveChannel(channelB) + try await store.saveMessage( + .testChannelMessage(radioID: radioA, channelIndex: 0, status: .failed) + ) + + let keysA = try await store.fetchFailedSendConversationKeys(radioID: radioA) + let keysB = try await store.fetchFailedSendConversationKeys(radioID: radioB) + + #expect(keysA.channelIDs == [channelA.id]) + #expect(!keysA.channelIDs.contains(channelB.id)) + #expect(keysB.channelIDs.isEmpty) + } + + @Test + func `failed channel message with no Channel row yields no channel id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + try await store.saveMessage( + .testChannelMessage(radioID: radioID, channelIndex: 3, status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.channelIDs.isEmpty) + } + + @Test + func `failed self room message includes that session and excludes other radios`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let otherRadioID = UUID() + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let otherSession = RemoteNodeSessionDTO.testSession( + radioID: otherRadioID, + publicKey: Data(repeating: 0xDD, count: 32) + ) + try await store.saveRemoteNodeSessionDTO(session) + try await store.saveRemoteNodeSessionDTO(otherSession) + + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: session.id, + text: "failed self send", + isFromSelf: true, + status: .failed + ) + ) + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: otherSession.id, + text: "other radio failed self send", + isFromSelf: true, + status: .failed + ) + ) + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: session.id, + text: "incoming failed", + isFromSelf: false, + status: .failed + ) + ) + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: session.id, + text: "self sent", + isFromSelf: true, + status: .sent + ) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + let otherKeys = try await store.fetchFailedSendConversationKeys(radioID: otherRadioID) + + #expect(keys.roomSessionIDs == [session.id]) + #expect(!keys.roomSessionIDs.contains(otherSession.id)) + #expect(otherKeys.roomSessionIDs == [otherSession.id]) + #expect(!otherKeys.roomSessionIDs.contains(session.id)) + } + + @Test + func `outgoing failed reaction is included`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + try await store.saveMessage( + .testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "👍", + status: .failed + ) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + } + + @Test + func `multiple failed DMs for one contact collapse to one id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: contactID, text: "one", status: .failed) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: contactID, text: "two", status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + } + + @Test + func `empty store returns empty keys`() async throws { + let store = try await createTestStore() + + let keys = try await store.fetchFailedSendConversationKeys(radioID: UUID()) + + #expect(keys == .empty) + } + + @Test + func `seen outgoing failed DM is absent`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + var message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + status: .failed + ) + message.failureSeen = true + try await store.saveMessage(message) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs.isEmpty) + } + + @Test + func `markFailedSendsSeen drops only that contact`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let seenID = UUID() + let otherID = UUID() + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: seenID, status: .failed) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: otherID, status: .failed) + ) + + try await store.markFailedSendsSeen(contactID: seenID) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [otherID]) + } + + @Test + func `markFailedSendsSeen drops only that channel index on that radio`() async throws { + let store = try await createTestStore() + let radioA = UUID() + let radioB = UUID() + let channelA0 = ChannelDTO.testChannel(radioID: radioA, index: 0, name: "A0") + let channelA1 = ChannelDTO.testChannel(radioID: radioA, index: 1, name: "A1") + let channelB0 = ChannelDTO.testChannel(radioID: radioB, index: 0, name: "B0") + try await store.saveChannel(channelA0) + try await store.saveChannel(channelA1) + try await store.saveChannel(channelB0) + try await store.saveMessage( + .testChannelMessage(radioID: radioA, channelIndex: 0, status: .failed) + ) + try await store.saveMessage( + .testChannelMessage(radioID: radioA, channelIndex: 1, status: .failed) + ) + try await store.saveMessage( + .testChannelMessage(radioID: radioB, channelIndex: 0, status: .failed) + ) + + try await store.markFailedSendsSeen(radioID: radioA, channelIndex: 0) + let keysA = try await store.fetchFailedSendConversationKeys(radioID: radioA) + let keysB = try await store.fetchFailedSendConversationKeys(radioID: radioB) + + #expect(keysA.channelIDs == [channelA1.id]) + #expect(keysB.channelIDs == [channelB0.id]) + } + + @Test + func `markFailedSendsSeen drops only that room session`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let seen = RemoteNodeSessionDTO.testSession(radioID: radioID) + let other = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: Data(repeating: 0xDD, count: 32), + name: "OtherRoom" + ) + try await store.saveRemoteNodeSessionDTO(seen) + try await store.saveRemoteNodeSessionDTO(other) + try await store.saveRoomMessage( + .testRoomMessage(sessionID: seen.id, isFromSelf: true, status: .failed) + ) + try await store.saveRoomMessage( + .testRoomMessage(sessionID: other.id, isFromSelf: true, status: .failed) + ) + + try await store.markFailedSendsSeen(roomSessionID: seen.id) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.roomSessionIDs == [other.id]) + } + + @Test + func `transition to failed from sent clears failureSeen`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + var message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + status: .sent + ) + message.failureSeen = true + try await store.saveMessage(message) + + try await store.updateMessageStatus(id: message.id, status: .failed) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + } + + @Test + func `idempotent failed write does not clear failureSeen`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + let message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + status: .failed + ) + try await store.saveMessage(message) + try await store.markFailedSendsSeen(contactID: contactID) + + try await store.updateMessageStatus(id: message.id, status: .failed) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs.isEmpty) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift index c20074482..fff7bd2c9 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift @@ -23,6 +23,7 @@ extension ContactDTO { latitude: Double = 0, longitude: Double = 0, lastModified: UInt32 = 0, + lastHeardTimestamp: UInt32? = nil, nickname: String? = nil, isBlocked: Bool = false, isMuted: Bool = false, @@ -45,6 +46,7 @@ extension ContactDTO { latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift index 9d807a649..2ea8fd27c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift @@ -6,6 +6,7 @@ extension PersistenceStore { static func createTestDataStore( radioID: UUID, maxChannels: UInt8 = 8, + maxContacts: UInt16 = 100, lastContactSync: UInt32 = 0 ) async throws -> PersistenceStore { let container = try PersistenceStore.createContainer(inMemory: true) @@ -15,6 +16,7 @@ extension PersistenceStore { radioID: radioID, firmwareVersion: 8, firmwareVersionString: "v1.0.0", + maxContacts: maxContacts, maxChannels: maxChannels, multiAcks: 0, lastContactSync: lastContactSync diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift index 23cdf18e2..d6f0420d2 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift @@ -17,7 +17,8 @@ extension RoomMessageDTO { timestamp: UInt32 = 1_700_000_000, createdAt: Date = Date(), isFromSelf: Bool = false, - status: MessageStatus = .delivered + status: MessageStatus = .delivered, + failureSeen: Bool = false ) -> RoomMessageDTO { RoomMessageDTO( id: id, @@ -28,7 +29,8 @@ extension RoomMessageDTO { timestamp: timestamp, createdAt: createdAt, isFromSelf: isFromSelf, - status: status + status: status, + failureSeen: failureSeen ) } } diff --git a/MC1Services/Tests/MC1ServicesTests/MessageLanguageDetectorTests.swift b/MC1Services/Tests/MC1ServicesTests/MessageLanguageDetectorTests.swift new file mode 100644 index 000000000..137dc6686 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/MessageLanguageDetectorTests.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("MessageLanguageDetector") +struct MessageLanguageDetectorTests { + private let german = "Guten Morgen, wie geht es dir heute?" + private let english = "Good morning, how are you today?" + + @Test + func `German sentence is detected`() { + #expect(MessageLanguageDetector.dominantLanguage(for: german) == .identified(languageCode: "de")) + } + + @Test + func `English sentence is detected and matches preferred en`() { + #expect(MessageLanguageDetector.dominantLanguage(for: english) == .identified(languageCode: "en")) + #expect(MessageLanguageDetector.isSameLanguage("en", "en-US")) + } + + @Test + func `too few letters is undetermined`() { + #expect(MessageLanguageDetector.dominantLanguage(for: "ok") == .undetermined) + #expect(MessageLanguageDetector.dominantLanguage(for: "kk") == .undetermined) + #expect(MessageLanguageDetector.dominantLanguage(for: "👍👍") == .undetermined) + #expect( + MessageLanguageDetector.dominantLanguage(for: "37.7749, -122.4194") == .undetermined + ) + #expect(MessageLanguageDetector.dominantLanguage(for: "") == .undetermined) + #expect(MessageLanguageDetector.dominantLanguage(for: " ") == .undetermined) + } + + @Test + func `eleven letters are below the floor`() { + let elevenLetters = "abcdefghijk" + #expect(elevenLetters.filter(\.isLetter).count == 11) + #expect(MessageLanguageDetector.dominantLanguage(for: elevenLetters) == .undetermined) + } + + @Test + func `zh-Hans and zh-Hant collapse to the same language`() { + #expect(MessageLanguageDetector.collapsedLanguageCode("zh-Hans") == "zh") + #expect(MessageLanguageDetector.collapsedLanguageCode("zh-Hant") == "zh") + #expect(MessageLanguageDetector.isSameLanguage("zh-Hans", "zh-Hant")) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/MessageTranslationChromeTests.swift b/MC1Services/Tests/MC1ServicesTests/MessageTranslationChromeTests.swift new file mode 100644 index 000000000..e0f6b111c --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/MessageTranslationChromeTests.swift @@ -0,0 +1,111 @@ +@testable import MC1Services +import Testing + +@Suite("MessageTranslationChrome.resolved") +struct MessageTranslationChromeTests { + @Test + func `detected German against English is an offer`() { + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: nil, + preferredLanguageCode: "en" + ) + #expect(chrome?.phase == .offer) + #expect(chrome?.sourceLanguageCode == "de") + } + + @Test + func `same language as preferred is none`() { + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: nil, + preferredLanguageCode: "de" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: nil, + preferredLanguageCode: "de-DE" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "zh"), + phase: nil, + preferredLanguageCode: "zh-Hans" + ) == nil + ) + } + + @Test + func `present nil detection is none`() { + #expect( + MessageTranslationChrome.resolved( + detected: nil, + phase: nil, + preferredLanguageCode: "en" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: nil, + phase: .offer, + preferredLanguageCode: "en" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: .undetermined, + phase: .offer, + preferredLanguageCode: "en" + ) == nil + ) + } + + @Test + func `inProgress is kept`() { + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: .inProgress, + preferredLanguageCode: "en" + ) + #expect(chrome?.phase == .inProgress) + } + + @Test + func `showing is kept when the target still matches preferred`() { + let showing = MessageTranslationChrome.Phase.showing( + translatedText: "Hello", + targetLanguageCode: "en" + ) + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: showing, + preferredLanguageCode: "en" + ) + #expect(chrome?.phase == showing) + } + + @Test + func `showing for a different target falls back to offer`() { + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: .showing(translatedText: "Hello", targetLanguageCode: "en"), + preferredLanguageCode: "fr" + ) + #expect(chrome?.phase == .offer) + } + + @Test + func `showing for a different target that matches the source is none`() { + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: .showing(translatedText: "Hello", targetLanguageCode: "en"), + preferredLanguageCode: "de" + ) == nil + ) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift index 160c4f19f..6f4034582 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift @@ -91,6 +91,15 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps /// Contacts to return from getContacts public var stubbedContacts: [MeshContact] = [] + /// Reported total for `getContactsReportingTotal`. `nil` returns + /// `stubbedContacts.count` (a complete stream). Set it above the stubbed + /// count to simulate a truncated stream. + public var stubbedReportedTotal: Int? + + /// When `true`, `getContactsReportingTotal` returns a `nil` total, simulating a + /// reply that carried no `contactsStart` header. + public var stubbedReportsNoTotal = false + /// Error to throw from getContacts public var stubbedGetContactsError: Error? @@ -110,6 +119,12 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps private var getContactHoldContinuations: [Data: CheckedContinuation] = [:] private var getContactHoldRequested: Set = [] + /// When true, the next `getContacts` parks until `releaseGetContacts()`. + private var getContactsHoldRequested = false + private var getContactsHoldActive = false + private var getContactsHoldContinuation: CheckedContinuation? + private var getContactsStartWaiters: [CheckedContinuation] = [] + /// Error to throw from addContact public var stubbedAddContactError: Error? @@ -239,6 +254,18 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps stubbedContacts = contacts } + /// Sets the reported total for `getContactsReportingTotal` (isolated setter). + /// Set it above the stubbed contact count to simulate a truncated stream. + public func setStubbedReportedTotal(_ total: Int?) { + stubbedReportedTotal = total + } + + /// Makes `getContactsReportingTotal` return a `nil` total (no `contactsStart` + /// header), through an isolated setter. + public func setStubbedReportsNoTotal(_ reportsNoTotal: Bool) { + stubbedReportsNoTotal = reportsNoTotal + } + /// Sets the self info returned by `currentSelfInfo`, through an isolated setter /// for the same actor-isolation reason as `setStubbedContacts`. public func setCurrentSelfInfo(_ selfInfo: SelfInfo?) { @@ -308,6 +335,28 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps getContactHoldContinuations[publicKey] != nil } + /// Causes the next `getContacts` to suspend until `releaseGetContacts()` is called. + public func holdNextGetContacts() { + getContactsHoldRequested = true + } + + /// Suspends until a held `getContacts` has entered its gate (claim + body in progress). + public func waitForGetContactsStart() async { + if getContactsHoldActive { return } + await withCheckedContinuation { getContactsStartWaiters.append($0) } + } + + /// Releases a held `getContacts` call. + public func releaseGetContacts() { + getContactsHoldContinuation?.resume() + getContactsHoldContinuation = nil + } + + /// Whether a `getContacts` call is currently suspended on the hold gate. + public func isGetContactsHeld() -> Bool { + getContactsHoldContinuation != nil + } + // MARK: - Protocol Methods public func sendMessage(to destination: Data, text: String, timestamp: Date, attempt: UInt8) async throws -> MessageSentInfo { @@ -328,11 +377,29 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps } public func getContacts(since lastModified: Date?) async throws -> [MeshContact] { + try await getContactsReportingTotal(since: lastModified).contacts + } + + public func getContactsReportingTotal(since lastModified: Date?) async throws -> ContactFetchResult { getContactsInvocations.append(lastModified) + if getContactsHoldRequested { + getContactsHoldRequested = false + getContactsHoldActive = true + while !getContactsStartWaiters.isEmpty { + getContactsStartWaiters.removeFirst().resume() + } + await withCheckedContinuation { (continuation: CheckedContinuation) in + getContactsHoldContinuation = continuation + } + getContactsHoldActive = false + } if let error = stubbedGetContactsError { throw error } - return stubbedContacts + return ContactFetchResult( + contacts: stubbedContacts, + reportedTotal: stubbedReportsNoTotal ? nil : (stubbedReportedTotal ?? stubbedContacts.count) + ) } public func getContact(publicKey: Data) async throws -> MeshContact? { diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 4d71b5c0c..94ee2236b 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -21,7 +21,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { public var stubbedUpdateMessageStatusError: Error? public var stubbedSaveContactError: Error? public var stubbedFetchContactError: Error? + public var stubbedFetchContactPublicKeysError: Error? public var stubbedDeleteContactError: Error? + public var stubbedTouchContactHeardError: Error? public var stubbedSaveChannelError: Error? public var stubbedFetchChannelError: Error? public var stubbedDeleteChannelError: Error? @@ -44,6 +46,16 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { public init() {} + // MARK: - Stub Configuration + + public func setStubbedFetchContactError(_ error: Error?) { + stubbedFetchContactError = error + } + + public func setStubbedTouchContactHeardError(_ error: Error?) { + stubbedTouchContactHeardError = error + } + // MARK: - Message Operations public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { @@ -96,6 +108,12 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return Array(filtered.dropFirst(offset).prefix(limit)) } + public func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? { + messages.values + .filter { $0.contactID == contactID && $0.direction == .incoming && !$0.isRead } + .max { $0.sortDate < $1.sortDate } + } + public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { if let error = stubbedFetchMessageError { throw error @@ -105,6 +123,55 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return Array(filtered.dropFirst(offset).prefix(limit)) } + public func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + try windowFrom( + messages.values.filter { $0.contactID == contactID }, + anchorSortDate: anchorSortDate, + floorLimit: floorLimit + ) + } + + public func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + try windowFrom( + messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex }, + anchorSortDate: anchorSortDate, + floorLimit: floorLimit + ) + } + + private func windowFrom( + _ candidates: some Sequence, + anchorSortDate: Date?, + floorLimit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + if let error = stubbedFetchMessageError { + throw error + } + let newestFirst = candidates.sorted { + if $0.sortDate != $1.sortDate { return $0.sortDate > $1.sortDate } + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.createdAt > $1.createdAt + } + let limit: Int = if let anchorSortDate { + max(floorLimit, newestFirst.filter { $0.sortDate >= anchorSortDate }.count) + } else { + floorLimit + } + let fetched = Array(newestFirst.prefix(limit + 1)) + let hasMore = fetched.count > limit + let window = hasMore ? Array(fetched.dropLast()) : fetched + return (MessageDTO.reorderSameSenderClusters(Array(window.reversed())), hasMore) + } + public func findChannelMessageForReaction( radioID: UUID, channelIndex: UInt8, @@ -568,6 +635,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: frame.latitude, longitude: frame.longitude, lastModified: frame.lastModified, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -580,12 +648,68 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return (id: id, isNew: true) } - public func deleteContactIfUnreferenced(id: UUID) async throws { - // Same actor region as the message map: probe and delete with no suspension between. - if messages.values.contains(where: { $0.contactID == id }) { - return + public var touchContactHeardCalls: [(radioID: UUID, publicKey: Data, date: Date)] = [] + + @discardableResult + public func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + touchContactHeardCalls.append((radioID, publicKey, date)) + if let error = stubbedTouchContactHeardError { + throw error } - try await deleteContact(id: id) + let stamp = UInt32(date.timeIntervalSince1970) + guard let existing = contacts.values.first(where: { $0.radioID == radioID && $0.publicKey == publicKey }) else { + return false + } + let merged = max(existing.lastHeardTimestamp ?? 0, stamp) + contacts[existing.id] = ContactDTO( + 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, + lastHeardTimestamp: merged, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isMuted: existing.isMuted, + isFavorite: existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + unreadMentionCount: existing.unreadMentionCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString, + avatarImageData: existing.avatarImageData + ) + // Mirrors PersistenceStore: a known contact without a Discover row gets one. + let node: DiscoveredNodeDTO = if let existingNode = discoveredNodes.values.first( + where: { $0.radioID == radioID && $0.publicKey == publicKey } + ) { + existingNode + } else { + try await upsertDiscoveredNode(radioID: radioID, from: existing.toContactFrame()).node + } + discoveredNodes[node.id] = DiscoveredNodeDTO( + id: node.id, + radioID: node.radioID, + publicKey: node.publicKey, + name: node.name, + typeRawValue: node.typeRawValue, + lastHeard: date, + lastAdvertTimestamp: node.lastAdvertTimestamp, + latitude: node.latitude, + longitude: node.longitude, + outPathLength: node.outPathLength, + outPath: node.outPath, + inboundHopCount: node.inboundHopCount, + inboundHopAdvertTimestamp: node.inboundHopAdvertTimestamp + ) + return true } public func saveContact(_ dto: ContactDTO) async throws { @@ -605,6 +729,101 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { contacts.removeValue(forKey: id) } + public func deleteContactIfUnreferenced(id: UUID) async throws { + // Same actor region as the message map: probe and delete with no suspension between. + if messages.values.contains(where: { $0.contactID == id }) { + return + } + try await deleteContact(id: id) + } + + public func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] { + let candidates = contacts + guard !candidates.isEmpty else { return [:] } + var adoptedCounts: [UUID: Int] = [:] + var newestDateByContact: [UUID: Date] = [:] + var unreadByContact: [UUID: Int] = [:] + var mentionByContact: [UUID: Int] = [:] + + for (id, message) in messages { + guard message.radioID == radioID, + message.contactID == nil, + message.channelIndex == nil, + message.direction == .incoming, + let prefix = message.senderKeyPrefix, + !prefix.isEmpty, + !ReactionParser.isReactionText(message.text, isDM: true) + else { continue } + + let matches = candidates.filter { + $0.publicKey.count >= prefix.count && $0.publicKey.prefix(prefix.count) == prefix + } + guard matches.count == 1, let match = matches.first else { continue } + + var updated = message + updated.contactID = match.id + updated.deduplicationKey = DeduplicationKey.contentBased( + contactID: match.id, + channelIndex: nil, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + messages[id] = updated + adoptedCounts[match.id, default: 0] += 1 + let messageDate = message.sortDate + if let existing = newestDateByContact[match.id] { + newestDateByContact[match.id] = max(existing, messageDate) + } else { + newestDateByContact[match.id] = messageDate + } + if !message.isRead { + unreadByContact[match.id, default: 0] += 1 + } + if message.containsSelfMention, !message.mentionSeen { + mentionByContact[match.id, default: 0] += 1 + } + } + + for (contactID, _) in adoptedCounts { + guard let contact = self.contacts[contactID] else { continue } + let newest = newestDateByContact[contactID] + let lastMessageDate: Date? = if let newest, let existing = contact.lastMessageDate { + max(existing, newest) + } else { + newest ?? contact.lastMessageDate + } + let unreadBump = contact.isBlocked ? 0 : (unreadByContact[contactID] ?? 0) + let mentionBump = contact.isBlocked ? 0 : (mentionByContact[contactID] ?? 0) + self.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, + lastHeardTimestamp: contact.lastHeardTimestamp, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: lastMessageDate, + unreadCount: contact.unreadCount + unreadBump, + unreadMentionCount: contact.unreadMentionCount + mentionBump + ) + } + return adoptedCounts + } + /// Mirrors the real store's cascade: messages, their pending sends, and /// reactions scoped to the contact die together, keyed by the contact ID /// value rather than the contact row. @@ -633,6 +852,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -659,6 +879,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -685,6 +906,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -749,6 +971,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -775,6 +998,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -801,6 +1025,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -1132,9 +1357,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { // 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 private(set) var updatedRxLogRegions: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] = [] + public private(set) var updatedChannelMessageRegions: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] + public private(set) var updatedDMMessageRegions: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] public func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws { mockRxLogEntries.append(dto) @@ -1160,15 +1385,22 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { 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 fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + Array( + mockRxLogEntries + .filter { $0.radioID == radioID && $0.transportCode != nil } + .sorted { $0.receivedAt > $1.receivedAt } + .prefix(limit) + ) } 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 { + public func batchUpdateRxLogRegion( + updates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] + ) async throws { updatedRxLogRegions.append(contentsOf: updates) } @@ -1183,18 +1415,22 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { } } + @discardableResult public func batchUpdateChannelMessageRegion( radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] { updatedChannelMessageRegions.append(contentsOf: updates) + return [] } + @discardableResult public func batchUpdateDMMessageRegion( radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] { updatedDMMessageRegions.append(contentsOf: updates) + return [] } // MARK: - Saved Trace Paths @@ -1678,7 +1914,14 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { } } + public func setStubbedFetchContactPublicKeysError(_ error: Error?) { + stubbedFetchContactPublicKeysError = error + } + public func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + if let error = stubbedFetchContactPublicKeysError { + throw error + } if let error = stubbedFetchContactError { throw error } diff --git a/MC1Services/Tests/MC1ServicesTests/Models/RegionScopeSemanticsTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/RegionScopeSemanticsTests.swift new file mode 100644 index 000000000..66ac8f4f3 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Models/RegionScopeSemanticsTests.swift @@ -0,0 +1,81 @@ +import Foundation +@testable import MC1Services +import MeshCore +import Testing + +@Suite("RegionScopeSemantics") +struct RegionScopeSemanticsTests { + @Test + func `storageFields maps none unique and ambiguous`() { + #expect(RegionScopeSemantics.storageFields(from: .none).regionScope == nil) + #expect(RegionScopeSemantics.storageFields(from: .none).regionScopeMatches == []) + + let unique = RegionScopeSemantics.storageFields(from: .unique("Germany")) + #expect(unique.regionScope == "Germany") + #expect(unique.regionScopeMatches == ["Germany"]) + + let ambiguous = RegionScopeSemantics.storageFields(from: .ambiguous(["de-hh", "de-by"])) + #expect(ambiguous.regionScope == nil) + #expect(ambiguous.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `coalesce prefers multi-match over sticky scope`() { + let result = RegionScopeSemantics.coalesce( + scope: "Germany", + matches: ["de-hh", "de-by"] + ) + #expect(result == .ambiguous(["de-by", "de-hh"])) + } + + @Test + func `coalesce unique from single match`() { + #expect( + RegionScopeSemantics.coalesce(scope: nil, matches: ["USA"]) == .unique("USA") + ) + } + + @Test + func `coalesce legacy unique from scope only`() { + #expect( + RegionScopeSemantics.coalesce(scope: "Bavaria", matches: []) == .unique("Bavaria") + ) + } + + @Test + func `coalesce none when both empty`() { + #expect(RegionScopeSemantics.coalesce(scope: nil, matches: []) == .none) + #expect(RegionScopeSemantics.coalesce(scope: " ", matches: ["", " "]) == .none) + } + + @Test + func `chipLabel joins ambiguous with slash separator`() { + #expect(RegionScopeSemantics.chipLabel(from: .none) == nil) + #expect(RegionScopeSemantics.chipLabel(from: .unique("Germany")) == "Germany") + #expect( + RegionScopeSemantics.chipLabel(from: .ambiguous(["de-by", "de-hh"])) + == "de-by / de-hh" + ) + } + + @Test + func `matchRegions multi-match never stores first-match as regionScope`() throws { + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0x01, 0x02, 0x03, 0x04]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, + payloadTypeBits: 5, + payload: payload + ) + let match = TransportCodeRegionResolver.matchRegions( + scopeKeys: [("First", scopeKey), ("Second", scopeKey)], + expectedTransportCode0: code, + payloadTypeBits: 5, + payload: payload + ) + let fields = RegionScopeSemantics.storageFields(from: match) + #expect(fields.regionScope == nil) + #expect(fields.regionScopeMatches.count == 2) + #expect(Set(fields.regionScopeMatches) == Set(["First", "Second"])) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift index a6f32d038..ffefc6670 100644 --- a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift @@ -79,6 +79,38 @@ struct NodeSettingsResponseParserTests { #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025") == nil) } + @Test + func `clock response text is extracted from a bare clock line and from an OK sync line`() { + #expect( + NodeSettingsResponseParser.clockResponseText(in: "06:40 - 18/4/2025 UTC") + == "06:40 - 18/4/2025 UTC" + ) + #expect( + NodeSettingsResponseParser.clockResponseText(in: "OK - clock set: 15:35 - 14/8/2026 UTC") + == "15:35 - 14/8/2026 UTC" + ) + #expect(NodeSettingsResponseParser.clockResponseText(in: "OK - clock set") == nil) + #expect(NodeSettingsResponseParser.clockResponseText(in: "Alpha Repeater") == nil) + } + + @Test + func `clock drift is node minus reference and nil when the text has no clock`() throws { + let node = try #require( + NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025 UTC") + ) + let now = node.addingTimeInterval(600) + let drift = NodeSettingsResponseParser.clockDrift( + fromClockResponse: "06:40 - 18/4/2025 UTC", + relativeTo: now + ) + #expect(drift == -600) + + #expect( + NodeSettingsResponseParser.clockDrift(fromClockResponse: "OK - clock set", relativeTo: now) + == nil + ) + } + // MARK: - Clock Sync @Test diff --git a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift index d7a544258..94c89db8e 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift @@ -20,7 +20,7 @@ struct PairingCancellationTests { // Persist a matching device row so the pre-picker stranded-association sweep skips // this accessory; the count-one assertion then isolates the cancellation cleanup as // the sole remover. - let store = manager.createStandalonePersistenceStore() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) manager.setTestState( @@ -75,7 +75,7 @@ struct PairingCancellationTests { // Persist a matching device row so the pre-picker stranded-association sweep skips // this accessory; the count-one assertion then isolates the cancellation cleanup as // the sole remover. - let store = manager.createStandalonePersistenceStore() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) manager.setTestState( diff --git a/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift index 1d61f45c7..10d013f95 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift @@ -23,7 +23,7 @@ struct PairingStrandedAssociationTests { // sweep treats it as a saved radio and leaves the accessory in place, so only a // reintroduced auth-arm cleanup could remove it. The count-zero assertion below // would then fail, guarding against that regression. - let store = manager.createStandalonePersistenceStore() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) @@ -144,7 +144,7 @@ struct PairingStrandedAssociationTests { let mockASK = env.accessorySetupKit let savedID = UUID() - let store = manager.createStandalonePersistenceStore() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: savedID)) mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: savedID, displayName: "saved")]) diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift index e20970d1d..2598520d2 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift @@ -215,6 +215,28 @@ struct PersistenceStoreBatchSyncTests { #expect(contact.isFavorite) } + @Test + func `batchSaveContacts preserves existing avatarImageData`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + let jpeg = Data(repeating: 0xAB, count: 32) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey(0x01), + name: "Original", + avatarImageData: jpeg + ) + try await store.saveContact(contact) + + _ = try await store.batchSaveContacts(radioID: radioID, from: [ + contactFrame(0x01, name: "Renamed") + ]) + + let stored = try #require(try await store.fetchContact(radioID: radioID, publicKey: publicKey(0x01))) + #expect(stored.name == "Renamed") + #expect(stored.avatarImageData == jpeg) + } + @Test func `batchSaveContacts with empty frames returns zero`() async throws { let radioID = UUID() diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreMessageWindowTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreMessageWindowTests.swift new file mode 100644 index 000000000..203081e2a --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreMessageWindowTests.swift @@ -0,0 +1,142 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("PersistenceStore message window") +struct PersistenceStoreMessageWindowTests { + private func makeStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + @Test + func `nil anchor applies the floor and reports hasMore`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + try await persistDirectMessages(store, radioID: radioID, contactID: contactID, timestamps: 1...10) + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: nil, + floorLimit: 3 + ) + #expect(window.messages.map(\.timestamp) == [8, 9, 10]) + #expect(window.hasMore) + } + + @Test + func `anchor widening beats the floor`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + try await persistDirectMessages(store, radioID: radioID, contactID: contactID, timestamps: 1...10) + let anchor = Date(timeIntervalSince1970: 4) + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: anchor, + floorLimit: 3 + ) + #expect(window.messages.map(\.timestamp) == [4, 5, 6, 7, 8, 9, 10]) + #expect(window.hasMore) + } + + @Test + func `tie rows at the anchor are included`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + let anchor = Date(timeIntervalSince1970: 5) + for timestamp in [3, 5, 5, 7] as [UInt32] { + try await store.saveMessage( + MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "m\(timestamp)", + timestamp: timestamp, + createdAt: timestamp == 5 ? anchor : Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) + ) + } + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: anchor, + floorLimit: 1 + ) + #expect(window.messages.filter { $0.sortDate == anchor }.count == 2) + #expect(window.messages.map(\.timestamp).contains(3) == false) + #expect(window.hasMore) + } + + @Test + func `probe row is dropped and hasMore is exact at the boundary`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + try await persistDirectMessages(store, radioID: radioID, contactID: contactID, timestamps: 1...4) + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: nil, + floorLimit: 3 + ) + #expect(window.messages.map(\.timestamp) == [2, 3, 4]) + #expect(window.hasMore) + + let exact = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: nil, + floorLimit: 4 + ) + #expect(exact.messages.map(\.timestamp) == [1, 2, 3, 4]) + #expect(exact.hasMore == false) + } + + @Test + func `channel window matches the contact variant`() async throws { + let store = try await makeStore() + let radioID = UUID() + let channelIndex: UInt8 = 2 + for timestamp in 1...5 as ClosedRange { + try await store.saveMessage( + MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + text: "c\(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) + ) + } + + let window = try await store.fetchMessageWindow( + radioID: radioID, + channelIndex: channelIndex, + anchorSortDate: Date(timeIntervalSince1970: 3), + floorLimit: 2 + ) + #expect(window.messages.map(\.timestamp) == [3, 4, 5]) + #expect(window.hasMore) + } + + private func persistDirectMessages( + _ store: PersistenceStore, + radioID: UUID, + contactID: UUID, + timestamps: ClosedRange + ) async throws { + for timestamp in timestamps { + try await store.saveMessage( + MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "m\(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) + ) + } + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift index 4423dc736..6b6b6dd2a 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift @@ -1539,6 +1539,7 @@ struct PersistenceStoreTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: true, isMuted: false, @@ -2105,10 +2106,12 @@ struct PersistenceStoreTests { radioID: UUID, senderTimestamp: UInt32? = nil, regionScope: String? = nil, + regionScopeMatches: [String] = [], payloadTypeBits: UInt8 = 5, transportCode: Data? = nil, channelIndex: UInt8? = 1, - packetPayload: Data = Data([0xAB, 0xCD, 0xEF]) + packetPayload: Data = Data([0xAB, 0xCD, 0xEF]), + receivedAt: Date = Date() ) -> RxLogEntryDTO { // Create minimal ParsedRxLogData for the DTO let parsed = ParsedRxLogData( @@ -2127,12 +2130,14 @@ struct PersistenceStoreTests { return RxLogEntryDTO( radioID: radioID, + receivedAt: receivedAt, from: parsed, channelIndex: channelIndex, channelName: "TestChannel", decryptStatus: .success, senderTimestamp: senderTimestamp, regionScope: regionScope, + regionScopeMatches: regionScopeMatches, decodedText: "Hello mesh!" ) } @@ -2315,12 +2320,78 @@ struct PersistenceStoreTests { let dto = createTestRxLogEntryDTO( radioID: device.id, senderTimestamp: 1_703_000_000, - regionScope: "Germany" + regionScope: "Germany", + regionScopeMatches: ["Germany"] ) try await store.saveRxLogEntry(dto) let entries = try await store.fetchRxLogEntries(radioID: device.id) #expect(entries.first?.regionScope == "Germany") + #expect(entries.first?.regionScopeMatches == ["Germany"]) + } + + @Test + func `batchUpdateRxLogRegion writes dual region fields`() 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_050, + regionScope: "Germany", + regionScopeMatches: ["Germany"], + transportCode: Data([0x12, 0x34]) + ) + try await store.saveRxLogEntry(dto) + let id = try #require(try await store.fetchRxLogEntries(radioID: device.id).first?.id) + + try await store.batchUpdateRxLogRegion(updates: [( + id: id, + regionScope: nil, + regionScopeMatches: ["de-by", "de-hh"] + )]) + + let updated = try #require(try await store.fetchRxLogEntries(radioID: device.id).first) + #expect(updated.regionScope == nil) + #expect(updated.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `fetchEntriesWithTransportCode includes labeled rows and honors limit`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let base = Date(timeIntervalSince1970: 1_700_000_000) + // Five transport-coded rows with distinct receivedAt; one nil-code row to exclude. + for index in 0..<5 { + try await store.saveRxLogEntry(createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(index), + regionScope: index == 0 ? "Germany" : nil, + regionScopeMatches: index == 0 ? ["Germany"] : [], + transportCode: Data([UInt8(index), 0x02]), + receivedAt: base.addingTimeInterval(TimeInterval(index)) + )) + } + try await store.saveRxLogEntry(createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: 99, + transportCode: nil, + receivedAt: base.addingTimeInterval(100) + )) + + let limit = 2 + let entries = try await store.fetchEntriesWithTransportCode(radioID: device.id, limit: limit) + #expect(entries.count == limit) + #expect(entries.allSatisfy { $0.transportCode != nil }) + // Newest-first by receivedAt: index 4 then 3 when sort is .reverse and fetchLimit applies. + #expect(entries.map(\.receivedAt) == [ + base.addingTimeInterval(4), + base.addingTimeInterval(3) + ]) + #expect(entries.map(\.senderTimestamp) == [4, 3]) } @Test @@ -2359,11 +2430,12 @@ struct PersistenceStoreTests { try await store.batchUpdateChannelMessageRegion( radioID: device.id, - updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany")] + updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany", regionScopeMatches: ["Germany"])] ) let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 0) #expect(saved.first?.regionScope == "Germany") + #expect(saved.first?.regionScopeMatches == ["Germany"]) } @Test @@ -2385,11 +2457,43 @@ struct PersistenceStoreTests { try await store.batchUpdateChannelMessageRegion( radioID: device.id, - updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA")] + updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA", regionScopeMatches: ["USA"])] ) let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 1) #expect(saved.first?.regionScope == "USA") + #expect(saved.first?.regionScopeMatches == ["USA"]) + } + + @Test + func `batchUpdateChannelMessageRegion writes multi-match regionScopeMatches with nil scope`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_666_666 + let dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 3, + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered + ) + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [( + channelIndex: 3, + senderTimestamp: wireTimestamp, + regionScope: nil, + regionScopeMatches: ["First", "Second"] + )] + ) + + let saved = try #require(try await store.fetchMessages(radioID: device.id, channelIndex: 3).first) + #expect(saved.regionScope == nil) + #expect(Set(saved.regionScopeMatches) == Set(["First", "Second"])) } @Test @@ -2410,7 +2514,7 @@ struct PersistenceStoreTests { try await store.batchUpdateChannelMessageRegion( radioID: device.id, - updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France")] + updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France", regionScopeMatches: ["France"])] ) let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 2) @@ -2438,11 +2542,12 @@ struct PersistenceStoreTests { try await store.batchUpdateDMMessageRegion( radioID: device.id, - updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany")] + updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany", regionScopeMatches: ["Germany"])] ) let saved = try await store.fetchMessages(contactID: contactID) #expect(saved.first?.regionScope == "Germany") + #expect(saved.first?.regionScopeMatches == ["Germany"]) } @Test @@ -2480,7 +2585,7 @@ struct PersistenceStoreTests { try await store.batchUpdateDMMessageRegion( radioID: device.id, - updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany")] + updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany", regionScopeMatches: ["Germany"])] ) let aliceSaved = try await store.fetchMessages(contactID: aliceContact) @@ -2491,6 +2596,450 @@ struct PersistenceStoreTests { // MARK: - saveContact isNew / deleteContactIfUnreferenced + @Test + func `deleteContactIfUnreferenced skips when messages exist and deletes when none`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let withMessagesID = try await store.saveContact( + radioID: device.id, from: createTestContactFrame(name: "WithMsgs") + ).id + try await store.saveMessage( + MessageDTO.testDirectMessage(radioID: device.id, contactID: withMessagesID, text: "keep") + ) + try await store.deleteContactIfUnreferenced(id: withMessagesID) + #expect(try await store.fetchContact(id: withMessagesID) != nil) + #expect(try await store.fetchMessages(contactID: withMessagesID, limit: 10, offset: 0).count == 1) + + let bareID = try await store.saveContact( + radioID: device.id, from: createTestContactFrame(name: "Bare") + ).id + try await store.deleteContactIfUnreferenced(id: bareID) + #expect(try await store.fetchContact(id: bareID) == nil) + } + + // MARK: - adoptOrphanedDirectMessages + + @Test + func `adoptOrphanedDirectMessages links orphan DM and updates unread`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "LateContact") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let stamp = UInt32(1_700_000_200) + let orphan = MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "hello orphan", + timestamp: stamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(stamp)), + sortDate: Date(timeIntervalSince1970: TimeInterval(stamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0, + containsSelfMention: true + ) + try await store.saveMessage(orphan) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted[contactID] == 1) + + let messages = try await store.fetchMessages(contactID: contactID, limit: 10, offset: 0) + #expect(messages.count == 1) + #expect(messages[0].contactID == contactID) + #expect(messages[0].deduplicationKey?.contains(contactID.uuidString) == true) + + let contact = try #require(await store.fetchContact(id: contactID)) + #expect(contact.unreadCount == 1) + #expect(contact.unreadMentionCount == 1) + #expect(contact.lastMessageDate != nil) + + // Idempotent: second run adopts nothing and does not double-count. + let second = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(second.isEmpty) + let after = try #require(await store.fetchContact(id: contactID)) + #expect(after.unreadCount == 1) + #expect(after.unreadMentionCount == 1) + } + + @Test + func `newestUnreadIncomingMessage returns the newest unread incoming only`() 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(name: "A") + ).id + let otherID = try await store.saveContact( + radioID: device.id, from: createTestContactFrame(name: "B") + ).id + + let base = Date(timeIntervalSince1970: 1_700_000_000) + func save( + _ text: String, contact: UUID, direction: MessageDirection, read: Bool, at offset: TimeInterval + ) async throws -> UUID { + let date = base.addingTimeInterval(offset) + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contact, + text: text, + timestamp: UInt32(date.timeIntervalSince1970), + createdAt: date, + sortDate: date, + directionRawValue: direction.rawValue, + isRead: read + )) + try await store.saveMessage(message) + return message.id + } + + _ = try await save("old unread incoming", contact: contactID, direction: .incoming, read: false, at: 10) + let newest = try await save("new unread incoming", contact: contactID, direction: .incoming, read: false, at: 30) + _ = try await save("newer but read", contact: contactID, direction: .incoming, read: true, at: 40) + _ = try await save("newer but outgoing", contact: contactID, direction: .outgoing, read: false, at: 50) + _ = try await save("other contact unread", contact: otherID, direction: .incoming, read: false, at: 60) + + let result = try await store.newestUnreadIncomingMessage(contactID: contactID) + #expect(result?.id == newest) + #expect(result?.text == "new unread incoming") + } + + @Test + func `newestUnreadIncomingMessage returns nil when no unread incoming exists`() 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()).id + + let read = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "already read", + timestamp: 1_700_000_100, + directionRawValue: MessageDirection.incoming.rawValue, + isRead: true + )) + try await store.saveMessage(read) + + #expect(try await store.newestUnreadIncomingMessage(contactID: contactID) == nil) + } + + @Test + func `adoptOrphanedDirectMessages never adopts a channel message`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + let frame = createTestContactFrame(name: "Only") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: 1, + text: "channel", + timestamp: 1_700_000_300, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: "Sender", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted.isEmpty) + let all = try await store.fetchAllMessages(radioID: device.id) + let row = try #require(all.first { $0.id == messageID }) + #expect(row.contactID == nil) + #expect(row.channelIndex == 1) + } + + @Test + func `adoptOrphanedDirectMessages never adopts an outgoing message`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + let frame = createTestContactFrame(name: "Only") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "outgoing", + timestamp: 1_700_000_301, + createdAt: Date(), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: true, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted.isEmpty) + let all = try await store.fetchAllMessages(radioID: device.id) + let row = try #require(all.first { $0.id == messageID }) + #expect(row.contactID == nil) + } + + @Test + func `adoptOrphanedDirectMessages never adopts reaction wire text`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + let frame = createTestContactFrame(name: "Only") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let messageID = UUID() + // Sole contact match: only the reaction skip can leave this row unlinked. + #expect(ReactionParser.isReactionText("r:abcd:01", isDM: true)) + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "r:abcd:01", + timestamp: 1_700_000_302, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted.isEmpty) + let all = try await store.fetchAllMessages(radioID: device.id) + let row = try #require(all.first { $0.id == messageID }) + #expect(row.contactID == nil) + } + + @Test + func `adoptOrphanedDirectMessages skips multi-match prefix`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frameA = createTestContactFrame(name: "A") + let idA = try await store.saveContact(radioID: device.id, from: frameA).id + var keyB = frameA.publicKey + keyB[6] = keyB[6] &+ 1 + let frameB = ContactFrame( + publicKey: keyB, + type: frameA.type, + flags: frameA.flags, + outPathLength: 0, + outPath: Data(), + name: "B", + lastAdvertTimestamp: frameA.lastAdvertTimestamp, + latitude: 0, + longitude: 0, + lastModified: frameA.lastModified + ) + let idB = try await store.saveContact(radioID: device.id, from: frameB).id + let prefix = Data(frameA.publicKey.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "ambiguous", + timestamp: 1_700_000_303, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let multi = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: idA, publicKey: frameA.publicKey), (id: idB, publicKey: frameB.publicKey)] + ) + #expect(multi.isEmpty, "shared prefix must leave orphans unlinked") + let all = try await store.fetchAllMessages(radioID: device.id) + #expect(all.first { $0.id == messageID }?.contactID == nil) + } + + @Test + func `adoptOrphanedDirectMessages adopts blocked contact without unread bump`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frameC = createTestContactFrame(name: "Blocked") + let idC = try await store.saveContact(radioID: device.id, from: frameC).id + let blockedDTO = try #require(await store.fetchContact(id: idC)) + try await store.saveContact( + ContactDTO.testContact( + id: idC, + radioID: device.id, + publicKey: frameC.publicKey, + name: "Blocked", + typeRawValue: blockedDTO.typeRawValue, + flags: blockedDTO.flags, + outPathLength: blockedDTO.outPathLength, + outPath: blockedDTO.outPath, + lastAdvertTimestamp: blockedDTO.lastAdvertTimestamp, + lastModified: blockedDTO.lastModified, + isBlocked: true + ) + ) + let cPrefix = Data(frameC.publicKey.prefix(6)) + try await store.saveMessage( + MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "blocked orphan", + timestamp: 1_700_000_304, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: cPrefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + let blockedAdopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: idC, publicKey: frameC.publicKey)] + ) + #expect(blockedAdopted[idC] == 1) + let blocked = try #require(await store.fetchContact(id: idC)) + #expect(blocked.unreadCount == 0) + #expect(try await store.fetchMessages(contactID: idC, limit: 5, offset: 0).count == 1) + } + + @Test + func `phone clock clamp agrees between touch and backup helpers`() async throws { + let store = try await createTestStore() + let farFuture = UInt32(Date().timeIntervalSince1970) &+ (60 * 60 * 24 * 30) + let now = Date() + let fromStatic = PersistenceStore.clampedPhoneClockTimestamp(farFuture, at: now) + let fromBackup = await store.clampedBackupLastHeardTimestamp(farFuture, at: now) + #expect(fromStatic == fromBackup) + #expect(fromStatic < farFuture) + } + + @Test + func `updateDeviceLastContactSync stores radio-ahead watermark without phone clamp`() async throws { + // lastContactSync is max(contact.lastmod) from the radio RTC, not the phone clock. + // Phone-clamping it pins the watermark below every contact lastmod when the radio + // leads the phone, so firmware lastmod > since matches the whole table every delta. + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let phoneNow = UInt32(Date().timeIntervalSince1970) + let radioAhead = phoneNow &+ (30 * 24 * 60 * 60) + try await store.updateDeviceLastContactSync(radioID: device.radioID, timestamp: radioAhead) + + let fetched = try #require(await store.fetchDevice(radioID: device.radioID)) + #expect(fetched.lastContactSync == radioAhead) + } + @Test func `saveContact from frame returns isNew true then false with stable id`() async throws { let store = try await createTestStore() @@ -2522,26 +3071,58 @@ struct PersistenceStoreTests { } @Test - func `deleteContactIfUnreferenced skips when messages exist and deletes when none`() async throws { + func `touchContactHeard bumps contact and existing discovered node`() async throws { let store = try await createTestStore() let device = createTestDevice() try await store.saveDevice(device) - let withMessagesID = try await store.saveContact( - radioID: device.id, from: createTestContactFrame(name: "WithMsgs") - ).id - try await store.saveMessage( - MessageDTO.testDirectMessage(radioID: device.id, contactID: withMessagesID, text: "keep") + let frame = createTestContactFrame(name: "Heard") + _ = try await store.saveContact(radioID: device.id, from: frame) + _ = try await store.upsertDiscoveredNode(radioID: device.id, from: frame) + + let stamp = Date(timeIntervalSince1970: 1_800_000_000) + let known = try await store.touchContactHeard( + radioID: device.id, publicKey: frame.publicKey, at: stamp ) - try await store.deleteContactIfUnreferenced(id: withMessagesID) - #expect(try await store.fetchContact(id: withMessagesID) != nil) - #expect(try await store.fetchMessages(contactID: withMessagesID, limit: 10, offset: 0).count == 1) + #expect(known == true) - let bareID = try await store.saveContact( - radioID: device.id, from: createTestContactFrame(name: "Bare") - ).id - try await store.deleteContactIfUnreferenced(id: bareID) - #expect(try await store.fetchContact(id: bareID) == nil) + let contact = try #require(await store.fetchContact(radioID: device.id, publicKey: frame.publicKey)) + #expect(contact.lastHeardTimestamp == 1_800_000_000) + + let nodes = try await store.fetchDiscoveredNodes(radioID: device.id) + let node = try #require(nodes.first { $0.publicKey == frame.publicKey }) + #expect(node.lastHeard == stamp) + + let unknown = try await store.touchContactHeard( + radioID: device.id, publicKey: Data(repeating: 0xEE, count: 32), at: stamp + ) + #expect(unknown == false) + } + + @Test + func `touchContactHeard creates a missing discovered node`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "NoDiscoverRow") + _ = try await store.saveContact(radioID: device.id, from: frame) + #expect(try await store.fetchDiscoveredNodes(radioID: device.id).isEmpty) + + let stamp = Date(timeIntervalSince1970: 1_800_000_500) + let known = try await store.touchContactHeard( + radioID: device.id, publicKey: frame.publicKey, at: stamp + ) + #expect(known == true) + + let nodes = try await store.fetchDiscoveredNodes(radioID: device.id) + let node = try #require(nodes.first { $0.publicKey == frame.publicKey }) + #expect(node.name == frame.name) + #expect(node.typeRawValue == frame.type.rawValue) + #expect(node.lastAdvertTimestamp == frame.lastAdvertTimestamp) + #expect(node.latitude == frame.latitude) + #expect(node.longitude == frame.longitude) + #expect(node.lastHeard == stamp) } // MARK: - Mute Tests diff --git a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift index 303851097..e76534586 100644 --- a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift @@ -31,6 +31,7 @@ struct RepeaterUnreadMigrationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift index 25f0ae552..2545d5869 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift @@ -27,29 +27,4 @@ struct ChatCoordinatorRegistryOfflineTests { #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 6b6f89d8b..c524ea6db 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift @@ -35,23 +35,6 @@ struct ChatCoordinatorRegistryTests { #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) diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift index e3dbd4f85..406bfac38 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift @@ -16,12 +16,18 @@ struct ChatCoordinatorTests { @Test func `initialPageSize grows to cover all unread plus read context`() { - // Unread beyond one page must all load at once, else the first-unread message - // (where the divider sits) would only page in later and the jump would have no target. + // Under the cap, unread beyond one page loads at once so the divider has a + // materialized target. Past maxInitialPageSize the fetch clamps. let unread = ChatCoordinator.pageSize + 70 let limit = ChatCoordinator.initialPageSize(unreadCount: unread) #expect(limit == unread + ChatCoordinator.dividerReadContext) - #expect(limit > unread, "Every unread message plus context must fit in the first page") + #expect(limit > unread, "Under the cap, unread plus context must fit in the first page") + } + + @Test + func `initialPageSize caps a huge unread backlog`() { + let unread = ChatCoordinator.maxInitialPageSize * 10 + #expect(ChatCoordinator.initialPageSize(unreadCount: unread) == ChatCoordinator.maxInitialPageSize) } @Test diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift index 7c2a3af71..9bc59c308 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift @@ -56,7 +56,8 @@ struct ChatSendQueueServiceAttemptCountTests { let contact = Contact( radioID: radioID, publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" + name: "Test Contact", + lastHeardTimestamp: 0 ) container.mainContext.insert(contact) try container.mainContext.save() diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift index e9e443377..fb80d92c0 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift @@ -81,7 +81,8 @@ struct ChatSendQueueServiceTests { let contact = Contact( radioID: radioID, publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" + name: "Test Contact", + lastHeardTimestamp: 0 ) container.mainContext.insert(contact) try container.mainContext.save() @@ -1154,7 +1155,8 @@ struct ChatSendQueueServiceTests { let contact = Contact( radioID: radioID, publicKey: Data(repeating: 0x33, count: 32), - name: "Test Contact" + name: "Test Contact", + lastHeardTimestamp: 0 ) container.mainContext.insert(contact) try container.mainContext.save() diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift index 055b51704..511990982 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift @@ -120,4 +120,198 @@ struct ContactServiceSyncTests { let stored = try await store.fetchContacts(radioID: radioID) #expect(Set(stored.map(\.name)) == ["Alice", "Existing"]) } + + @Test + func `Manual refresh waits for an in-flight advert delta sync`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: 8, + lastContactSync: 1_704_067_200 + ) + let coordinator = SyncCoordinator() + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + let service = ContactService( + session: session, + dataStore: store, + syncCoordinator: coordinator, + cleanupCoordinator: nil + ) + + let gated = GatedSyncContactService() + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: gated + ) + } + await gated.waitForSyncStart() + + let refreshTask = Task { try await service.syncContactsForRefresh(radioID: radioID) } + + try await Task.sleep(for: .milliseconds(200)) + #expect( + await session.getContactsInvocations.isEmpty, + "A manual refresh must not fetch while an advert delta sync holds the claim" + ) + + await gated.release() + #expect(await advertTask.value == .synced) + _ = try await refreshTask.value + #expect(await session.getContactsInvocations.count == 1) + } + + @Test + func `Manual refresh claim is atomic so a racing advert delta returns busy`() async throws { + // claimManualContactSync waits and sets the flag in one actor method so a racing + // performAdvertContactSync either waits behind the claim or sees manual = true. + // Separate wait and claim hops leave a gap where a delta can pass both. + // + // The refresh body is gated at getContacts so the test can observe the claim held + // before racing a second advert. Without that barrier the refresh can finish (and + // clear the flag) or not yet claim before the race is evaluated. + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: 8, + lastContactSync: 1_704_067_200 + ) + let coordinator = SyncCoordinator() + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + await session.holdNextGetContacts() + let service = ContactService( + session: session, + dataStore: store, + syncCoordinator: coordinator, + cleanupCoordinator: nil + ) + + let gated = GatedSyncContactService() + let firstAdvert = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: gated + ) + } + await gated.waitForSyncStart() + + let refreshTask = Task { try await service.syncContactsForRefresh(radioID: radioID) } + // Refresh is parked in the claim wait; release the first advert so the claim lands. + await gated.release() + #expect(await firstAdvert.value == .synced) + + // Claim + set manual finished, refresh body parked in getContacts. + await session.waitForGetContactsStart() + #expect(await session.isGetContactsHeld()) + + // Claim is held for the whole refresh body. A new advert round must return .busy + // rather than entering syncContacts and racing progress events. + let secondAdvert = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: MockContactService() + ) + #expect(secondAdvert == .busy, "Manual claim must block advert delta for the whole refresh") + + await session.releaseGetContacts() + _ = try await refreshTask.value + #expect(await session.getContactsInvocations.count == 1) + } + + @Test + func `Manual refresh surfaces syncInterrupted when the advert claim wait times out`() async throws { + // ContactService maps a timed-out claim to syncInterrupted so the refresh + // spinner stops with an error rather than hanging or racing the advert stream. + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: 8, + lastContactSync: 1_704_067_200 + ) + let coordinator = SyncCoordinator() + await coordinator.setAdvertContactSyncWaitTimeoutOverride(.milliseconds(40)) + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + let service = ContactService( + session: session, + dataStore: store, + syncCoordinator: coordinator, + cleanupCoordinator: nil + ) + + let gated = GatedSyncContactService() + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: gated + ) + } + await gated.waitForSyncStart() + + var surfaced: ContactServiceError? + do { + _ = try await service.syncContactsForRefresh(radioID: radioID) + } catch let error as ContactServiceError { + surfaced = error + } catch { + Issue.record("Expected ContactServiceError.syncInterrupted, got \(error)") + } + + guard case .syncInterrupted = surfaced else { + Issue.record("Refresh must map wait timeout to syncInterrupted, got \(String(describing: surfaced))") + await gated.release() + _ = await advertTask.value + return + } + #expect( + await session.getContactsInvocations.isEmpty, + "Timed-out refresh must not start a contact fetch" + ) + + await gated.release() + _ = await advertTask.value + } +} + +/// Contact service stub that parks inside `syncContacts` until released, so a test can +/// hold an advert-driven delta sync open. +private actor GatedSyncContactService: ContactServiceProtocol { + private var hasStarted = false + private var startWaiters: [CheckedContinuation] = [] + private var gate: CheckedContinuation? + private var isReleased = false + + func waitForSyncStart() async { + if hasStarted { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func release() { + isReleased = true + gate?.resume() + gate = nil + } + + func syncContacts(radioID _: UUID, since _: Date?) async throws -> ContactSyncResult { + hasStarted = true + while !startWaiters.isEmpty { + startWaiters.removeFirst().resume() + } + if !isReleased { + await withCheckedContinuation { gate = $0 } + } + return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: true) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift index 94af370d9..371fb9d0e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift @@ -438,6 +438,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -504,6 +505,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -585,6 +587,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -640,6 +643,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -689,6 +693,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -736,6 +741,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -784,6 +790,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -838,6 +845,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "MyNickname", isBlocked: false, isMuted: false, @@ -891,6 +899,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: true, isMuted: false, @@ -977,6 +986,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "OldNickname", isBlocked: false, isMuted: false, @@ -1018,6 +1028,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "OldNickname", isBlocked: false, isMuted: false, @@ -1059,6 +1070,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1100,6 +1112,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "KeepMe", isBlocked: false, isMuted: true, diff --git a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift index eed840a1e..3a34962d6 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift @@ -126,7 +126,7 @@ struct HeardRepeatsServiceTests { text: "north repeater check", timestamp: sendTimestamp )) - await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + await service.configure(radioID: radioID) let events = service.events() let echo = makeEcho( @@ -162,7 +162,7 @@ struct HeardRepeatsServiceTests { text: "hello", timestamp: sendTimestamp )) - await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + await service.configure(radioID: radioID) let echo = makeEcho( radioID: radioID, @@ -180,7 +180,7 @@ struct HeardRepeatsServiceTests { } @Test - func `no match for unknown timestamp or foreign sender`() async throws { + func `no match for unknown timestamp`() async throws { let (store, service) = try makeStoreAndService() let radioID = UUID() let channelIndex: UInt8 = 1 @@ -191,7 +191,7 @@ struct HeardRepeatsServiceTests { text: "hello", timestamp: sendTimestamp )) - await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + await service.configure(radioID: radioID) let wrongTimestamp = makeEcho( radioID: radioID, @@ -200,15 +200,6 @@ struct HeardRepeatsServiceTests { body: "hello" ) #expect(await service.processForRepeats(wrongTimestamp) == nil) - - let foreignSender = makeEcho( - radioID: radioID, - channelIndex: channelIndex, - senderTimestamp: sendTimestamp, - body: "hello", - senderName: "SomeoneElse" - ) - #expect(await service.processForRepeats(foreignSender) == nil) } @Test @@ -236,4 +227,38 @@ struct HeardRepeatsServiceTests { ) #expect(matchA?.id == aID) } + + /// Air prefix is not a join key. Body, timestamp, and channel still match. + @Test + func `new-node rename then three TEST Hello echoes attach`() async throws { + let (store, service) = try makeStoreAndService() + let radioID = UUID() + let channelIndex: UInt8 = 0 + let sendTimestamp = UInt32(Date().timeIntervalSince1970) + let messageID = UUID() + try await store.saveMessage(MessageDTO.testChannelMessage( + id: messageID, + radioID: radioID, + channelIndex: channelIndex, + text: "Hello", + timestamp: sendTimestamp + )) + await service.configure(radioID: radioID) + + var lastCount: Int? + for _ in 0..<3 { + let echo = makeEcho( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: sendTimestamp, + body: "Hello", + senderName: "TEST" + ) + lastCount = await service.processForRepeats(echo) + } + + #expect(lastCount == 3) + let repeats = try await store.fetchMessageRepeats(messageID: messageID) + #expect(repeats.count == 3) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift index 09b8a35ec..039b0e639 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift @@ -20,6 +20,7 @@ struct RegionDiscoveryServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteCLICommandRewriterTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteCLICommandRewriterTests.swift new file mode 100644 index 000000000..a1e4dce38 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteCLICommandRewriterTests.swift @@ -0,0 +1,30 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("RemoteCLICommandRewriter") +struct RemoteCLICommandRewriterTests { + private static let now = Date(timeIntervalSince1970: 1_786_722_487) + private static let expected = "time 1786722487" + + @Test + func `clock sync becomes time with the host epoch`() { + #expect(RemoteCLICommandRewriter.rewrite("clock sync", now: Self.now) == Self.expected) + } + + @Test(arguments: ["CLOCK SYNC", " clock sync ", "Clock Sync"]) + func `clock sync match is case and whitespace insensitive`(command: String) { + #expect(RemoteCLICommandRewriter.rewrite(command, now: Self.now) == Self.expected) + } + + @Test(arguments: ["clock", "time 123", "clock sync extra", "sync_time", "st"]) + func `other commands are left unchanged`(command: String) { + #expect(RemoteCLICommandRewriter.rewrite(command, now: Self.now) == command) + } + + @Test + func `pre epoch dates saturate to zero`() { + let preEpoch = Date(timeIntervalSince1970: -1000) + #expect(RemoteCLICommandRewriter.rewrite("clock sync", now: preEpoch) == "time 0") + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift index 4c6eb35fa..b3ff4c64d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift @@ -235,4 +235,32 @@ struct RemoteNodeCLICorrelationTests { await yieldReply(prefix + "US/CA^", to: harness.session) #expect(try await commandTask.value == "US/CA^") } + + @Test + func `clock sync is rewritten to time with the host epoch on the wire`() async throws { + let harness = try await makeHarness() + let before = UInt32(Date().timeIntervalSince1970) + + let commandTask = Task { + try await harness.service.sendRawCLICommand( + sessionID: harness.sessionID, + command: "clock sync" + ) + } + try await waitUntil("command was never sent") { + await harness.session.sendCommandInvocations.count == 1 + } + + let after = UInt32(Date().timeIntervalSince1970) + let sent = await harness.session.sendCommandInvocations.last?.command ?? "" + let split = try #require(CLIResponse.splitEchoedPrefix(sent)) + #expect(split.body.hasPrefix(RemoteCLICommandRewriter.timeCommandPrefix)) + let epochText = String(split.body.dropFirst(RemoteCLICommandRewriter.timeCommandPrefix.count)) + let epoch = try #require(UInt32(epochText)) + #expect(epoch >= before && epoch <= after) + + let prefix = try await sentWirePrefix(of: harness.session) + await yieldReply(prefix + "OK - clock set: 15:48 - 14/8/2026 UTC", to: harness.session) + #expect(try await commandTask.value == "OK - clock set: 15:48 - 14/8/2026 UTC") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceRegionReprocessTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceRegionReprocessTests.swift new file mode 100644 index 000000000..a581b7443 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceRegionReprocessTests.swift @@ -0,0 +1,485 @@ +import Foundation +@testable import MC1Services +@testable import MeshCore +import Testing + +@Suite("RxLogService region multi-match reprocess", .serialized) +struct RxLogServiceRegionReprocessTests { + @Test + func `replaceScopeKeyCache rewrites sticky first-match to ambiguous multi-match`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["First"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0x10, 0x20, 0x30, 0x40, 0x50]) + let payloadTypeBits: UInt8 = 5 + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, + payloadTypeBits: payloadTypeBits, + payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_000_000 + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: payloadTypeBits, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: 0, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: "First", + regionScopeMatches: ["First"] + ) + try await dataStore.saveRxLogEntry(entry) + + // Correlated channel message must follow the RxLog sticky → ambiguous rewrite. + let message = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let stream = service.regionUpdateEvents() + let collector = IDCollector() + let task = Task { + for await ids in stream { + await collector.record(ids) + if await collector.count > 0 { break } + } + } + + // Two display names sharing one scope key — the multi-match fixture. + await service.replaceScopeKeyCacheAndReprocess([ + (name: "First", key: scopeKey), + (name: "Second", key: scopeKey) + ]) + + let updated = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(updated.regionScope == nil, "ambiguous must clear sticky regionScope") + #expect(Set(updated.regionScopeMatches) == Set(["First", "Second"])) + + try await waitUntil("region update event should fire for multi-match message rewrite") { + await collector.count > 0 + } + task.cancel() + + let receivedIDs = await collector.ids + #expect(receivedIDs.contains(message.id)) + + let savedMessages = try await dataStore.fetchMessages(radioID: radioID, channelIndex: 0) + let savedMessage = try #require(savedMessages.first { $0.id == message.id }) + #expect(savedMessage.regionScope == nil, "ambiguous must clear message regionScope") + #expect(Set(savedMessage.regionScopeMatches) == Set(["First", "Second"])) + } + + @Test + func `empty known regions clears sticky labels`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Germany"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0xAA, 0xBB, 0xCC]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + decryptStatus: .success, + senderTimestamp: 1_704_000_100, + regionScope: "Germany", + regionScopeMatches: ["Germany"] + ) + try await dataStore.saveRxLogEntry(entry) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + // Wait for loadSecrets so knownRegions is ["Germany"] before clearing. + // Calling updateKnownRegions([]) while still default-empty is a no-op. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Germany"] + } + + await service.updateKnownRegions([]) + + let updated = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(updated.regionScope == nil) + #expect(updated.regionScopeMatches == []) + } + + @Test + func `live resolve persists unique name and matches array together`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Germany"] }) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + // Wait for loadSecrets so the live path resolves against the device regions. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Germany"] + } + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0x01, 0x02, 0x03, 0x04]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + await service.process(parsed) + + let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) + let live = try #require(entries.first { $0.transportCode == transportCode }) + #expect(live.regionScope == "Germany") + #expect(live.regionScopeMatches == ["Germany"]) + } + + @Test + func `regionUpdateEvents yields message IDs after channel message reprocess`() 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) + + // Non-matching seed so loadSecrets can finish without resolving the Germany code. + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Placeholder"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_111_000 + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: 0, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: nil, + regionScopeMatches: [] + ) + try await dataStore.saveRxLogEntry(entry) + + let message = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let stream = service.regionUpdateEvents() + let collector = IDCollector() + let task = Task { + for await ids in stream { + await collector.record(ids) + if await collector.count > 0 { break } + } + } + + // Wait for loadSecrets so its empty/placeholder cache cannot clobber Germany. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Placeholder"] + } + + await service.updateKnownRegions(["Germany"]) + + try await waitUntil("region update event should fire for correlated message") { + await collector.count > 0 + } + task.cancel() + + let receivedIDs = await collector.ids + #expect(receivedIDs.contains(message.id)) + let saved = try await dataStore.fetchMessages(radioID: radioID, channelIndex: 0) + #expect(saved.first?.regionScope == "Germany") + #expect(saved.first?.regionScopeMatches == ["Germany"]) + } + + @Test + func `regionUpdateEvents yields message IDs after DM message reprocess`() 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) + + // Non-matching seed so loadSecrets can finish without resolving the Germany code. + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Placeholder"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + // Byte at offset 1 is the unencrypted sender prefix used for DM correlation. + let senderPrefixByte: UInt8 = 0xAB + let packetPayload = Data([0x00, senderPrefixByte, 0xCC, 0xDD, 0xEE]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: packetPayload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_222_000 + let contactID = UUID() + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: packetPayload, + routeType: .tcFlood, + payloadType: .textMessage, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: packetPayload + ) + // nil channelIndex marks a DM entry; correlation uses packetPayload[1]. + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: nil, + channelName: nil, + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: nil, + regionScopeMatches: [] + ) + try await dataStore.saveRxLogEntry(entry) + + let message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: Data([senderPrefixByte, 0x11, 0x22, 0x33]) + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let stream = service.regionUpdateEvents() + let collector = IDCollector() + let task = Task { + for await ids in stream { + await collector.record(ids) + if await collector.count > 0 { break } + } + } + + // Wait for loadSecrets so its empty/placeholder cache cannot clobber Germany. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Placeholder"] + } + + await service.updateKnownRegions(["Germany"]) + + try await waitUntil("region update event should fire for correlated DM") { + await collector.count > 0 + } + task.cancel() + + let receivedIDs = await collector.ids + #expect(receivedIDs.contains(message.id)) + + let saved = try #require(try await dataStore.fetchMessage(id: message.id)) + #expect(saved.regionScope == "Germany") + #expect(saved.regionScopeMatches == ["Germany"]) + } + + @Test + func `reprocess overwrites intermediate resolution with final cache`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = [] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0xCA, 0xFE, 0xBA, 0xBE]) + let payloadTypeBits: UInt8 = 5 + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, + payloadTypeBits: payloadTypeBits, + payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_333_000 + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: payloadTypeBits, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: 0, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: "Stale", + regionScopeMatches: ["Stale"] + ) + try await dataStore.saveRxLogEntry(entry) + + let message = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + // Intermediate unique cache must land before the final multi-match overwrite. + await service.replaceScopeKeyCacheAndReprocess([(name: "Germany", key: scopeKey)]) + + let mid = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(mid.regionScope == "Germany") + #expect(mid.regionScopeMatches == ["Germany"]) + + await service.replaceScopeKeyCacheAndReprocess([ + (name: "First", key: scopeKey), + (name: "Second", key: scopeKey) + ]) + + let finalEntry = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(finalEntry.regionScope == nil) + #expect(Set(finalEntry.regionScopeMatches) == Set(["First", "Second"])) + + let finalMessage = try #require(try await dataStore.fetchMessage(id: message.id)) + #expect(finalMessage.regionScope == nil) + #expect(Set(finalMessage.regionScopeMatches) == Set(["First", "Second"])) + } + + private actor IDCollector { + private(set) var ids: [UUID] = [] + var count: Int { + ids.count + } + + func record(_ newIDs: [UUID]) { + ids.append(contentsOf: newIDs) + } + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift index ae756436a..0af4d5859 100644 --- a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift @@ -64,10 +64,40 @@ struct SimulatorSeedTests { @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)) + let message = try #require(try await store.fetchMessage(id: MockDataProvider.frankFloodUniqueMessageID)) #expect(message.routeType == .tcFlood) - #expect(message.regionScope == "US915") + #expect(message.regionScope == MockDataProvider.uniqueRegionName) + #expect(message.regionScopeMatches == [MockDataProvider.uniqueRegionName]) + } + + @Test + func `ambiguous flood dual fields round trip`() async throws { + let store = try await seededStore() + + let dm = try #require(try await store.fetchMessage(id: MockDataProvider.frankFloodAmbiguousMessageID)) + #expect(dm.routeType == .tcFlood) + #expect(dm.regionScope == nil) + #expect(dm.regionScopeMatches == MockDataProvider.ambiguousRegionNames) + + let channel = try #require(try await store.fetchMessage(id: MockDataProvider.publicAmbiguousRegionMessageID)) + #expect(channel.routeType == .tcFlood) + #expect(channel.regionScope == nil) + #expect(channel.regionScopeMatches == MockDataProvider.ambiguousRegionNames) + #expect(channel.pathNodes == Data([0x10, 0x20])) + } + + @Test + func `rx log region rows land`() async throws { + let store = try await seededStore() + let entries = try await store.fetchRxLogEntries(radioID: radioID) + let unique = try #require(entries.first { $0.id == MockDataProvider.uniqueRxLogEntryID }) + #expect(unique.regionScope == MockDataProvider.uniqueRegionName) + #expect(unique.regionScopeMatches == [MockDataProvider.uniqueRegionName]) + #expect(unique.transportCode?.isEmpty == false) + + let ambiguous = try #require(entries.first { $0.id == MockDataProvider.ambiguousRxLogEntryID }) + #expect(ambiguous.regionScope == nil) + #expect(ambiguous.regionScopeMatches == MockDataProvider.ambiguousRegionNames) } @Test @@ -81,6 +111,24 @@ struct SimulatorSeedTests { #expect(snapshots.contains { $0.latitude != nil && $0.longitude != nil }) } + @Test + func `reseeding preserves a user-set avatarImageData`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let mode = SimulatorConnectionMode() + try await mode.seedDataStore(store) + + let jpeg = Data(repeating: 0xCD, count: 16) + let aliceID = MockDataProvider.aliceChenID + let existing = try #require(try await store.fetchContact(id: aliceID)) + try await store.saveContact(existing.with(avatarImageData: jpeg)) + #expect(try await store.fetchContact(id: aliceID)?.avatarImageData == jpeg) + + try await mode.seedDataStore(store) + + #expect(try await store.fetchContact(id: aliceID)?.avatarImageData == jpeg) + } + @Test func `reseeding is idempotent`() async throws { let container = try PersistenceStore.createContainer(inMemory: true) @@ -96,5 +144,7 @@ struct SimulatorSeedTests { #expect(repeats.count == 3) let reactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) #expect(reactions.count == 3) + let rx = try await store.fetchRxLogEntries(radioID: radioID) + #expect(rx.count == 2) } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift index 3db4db3e6..395d7b6d6 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift @@ -234,4 +234,170 @@ struct SyncCoordinatorMessageHandlerTests { ) #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: channel) == true) } + + // MARK: - lastHeard mesh-liveness stamps + + @Test + func `inbound DM stamps contact lastHeard`() async throws { + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + let publicKey = Data(repeating: 0xAB, count: 32) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "Peer", + lastHeardTimestamp: 0 + ) + try await dataStore.saveContact(contact) + + let mockPolling = MockMessagePollingService() + let (_, services) = try await createTestServices() + let dependencies = services.syncDependencies + .with(dataStore: dataStore, messagePollingService: mockPolling) + + let coordinator = SyncCoordinator() + await coordinator.wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + let message = ContactMessage( + senderPublicKeyPrefix: Data(publicKey.prefix(6)), + pathLength: 0, + textType: 0, + senderTimestamp: Date(), + signature: nil, + text: "hello mesh", + snr: nil + ) + await mockPolling.capturedContactMessageHandler?(message, contact, .live) + + let updated = try #require( + await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect((updated.lastHeardTimestamp ?? 0) > 0) + } + + @Test + func `inbound channel message does not stamp lastHeard on a contact`() async throws { + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + let publicKey = Data(repeating: 0xCD, count: 32) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "ChannelPeer", + lastHeardTimestamp: 0 + ) + try await dataStore.saveContact(contact) + + let mockPolling = MockMessagePollingService() + let (_, services) = try await createTestServices() + let dependencies = services.syncDependencies + .with(dataStore: dataStore, messagePollingService: mockPolling) + + let coordinator = SyncCoordinator() + await coordinator.wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + let channelMessage = ChannelMessage( + channelIndex: 0, + pathLength: 0, + textType: 0, + senderTimestamp: Date(), + text: "ChannelPeer: hello channel", + snr: nil + ) + await mockPolling.capturedChannelMessageHandler?(channelMessage, nil, .live) + + let updated = try #require( + await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect((updated.lastHeardTimestamp ?? 0) == 0) + } + + // MARK: - Live receive region propagation + + @Test + func `live channel receive copies regionScope and regionScopeMatches from RxLog onto Message`() async throws { + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + let channelIndex: UInt8 = 0 + // Fixed integer epoch so ChannelMessage.senderTimestamp and RxLogEntry + // correlate without fractional-second truncation. + let senderTimestamp: UInt32 = 1_704_000_500 + let expectedScope: String? = "Germany" + let expectedMatches = ["Germany"] + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: Data([0x10, 0x20, 0x30]), + routeType: .flood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: nil, + pathLength: 0, + pathNodes: [], + packetPayload: Data([0xAA, 0xBB, 0xCC]) + ) + let rxEntry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: channelIndex, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: expectedScope, + regionScopeMatches: expectedMatches + ) + try await dataStore.saveRxLogEntry(rxEntry) + + let mockPolling = MockMessagePollingService() + let (_, services) = try await createTestServices() + let dependencies = services.syncDependencies + .with(dataStore: dataStore, messagePollingService: mockPolling) + + let coordinator = SyncCoordinator() + await coordinator.wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + let channelMessage = ChannelMessage( + channelIndex: channelIndex, + pathLength: 0, + textType: 0, + senderTimestamp: Date(timeIntervalSince1970: TimeInterval(senderTimestamp)), + text: "NodeAlpha: region scope test", + snr: nil + ) + await mockPolling.capturedChannelMessageHandler?(channelMessage, nil, .live) + + let saved = try await dataStore.fetchMessages(radioID: radioID, channelIndex: channelIndex) + let message = try #require(saved.first) + #expect(message.regionScope == expectedScope) + #expect(Set(message.regionScopeMatches) == Set(expectedMatches)) + } +} + +// MARK: - SyncDependencies test helpers + +extension SyncDependencies { + /// Copy with a different data store and message polling service for tests. + func with( + dataStore: any PersistenceStoreProtocol, + messagePollingService: any MessagePollingServiceProtocol + ) -> 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: startEventMonitoring, + exportPrivateKey: exportPrivateKey + ) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift index 2c23800e9..a4f18ba70 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift @@ -10,15 +10,58 @@ struct SyncCoordinatorTests { private func createTestDataStore( radioID: UUID, maxChannels: UInt8 = 8, + maxContacts: UInt16 = 100, lastContactSync: UInt32 = 0 ) async throws -> PersistenceStore { try await PersistenceStore.createTestDataStore( radioID: radioID, maxChannels: maxChannels, + maxContacts: maxContacts, lastContactSync: lastContactSync ) } + private func contactFrame( + key: Data, + name: String, + flags: UInt8 = 0, + lastModified: UInt32 = 1_700_000_100 + ) -> ContactFrame { + ContactFrame( + publicKey: key, + type: .chat, + flags: flags, + outPathLength: 0, + outPath: Data(), + name: name, + lastAdvertTimestamp: 1_700_000_000, + latitude: 0, + longitude: 0, + lastModified: lastModified + ) + } + + private func meshContact( + key: Data, + name: String, + flags: ContactFlags = ContactFlags(rawValue: 0), + lastModified: Date = Date(timeIntervalSince1970: 1_800_000_000) + ) -> MeshContact { + MeshContact( + id: key.hexString, + publicKey: key, + type: .chat, + flags: flags, + outPathLength: 0, + outPath: Data(), + advertisedName: name, + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, + longitude: 0, + lastModified: lastModified + ) + } + @Test func `SyncState cases are distinct`() { let idle = SyncState.idle @@ -633,13 +676,14 @@ struct SyncCoordinatorTests { let invocations = await mockContactService.syncContactsInvocations #expect(invocations.count == 1) - // Verify the since parameter was passed + // The device filter is strictly greater-than, so the window is rewound one + // second to include contacts modified in the watermark second itself. let since = invocations[0].since - let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp)) + let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp) - 1) // 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") + #expect(actualSince == expectedDate, "Since date should include the watermark second") } // MARK: - Succeeded Parameter Tests @@ -709,148 +753,1540 @@ struct SyncCoordinatorTests { #expect(succeededValues.values == [false], "Failed sync should pass succeeded: false") } - // MARK: - Resync Activity Bracket Tests + // MARK: - Advert Contact Delta Sync @Test @MainActor - func `beginResyncActivity and endResyncActivity fire the correct callbacks`() async { + func `performAdvertContactSync writes watermark and uses since filter`() async throws { let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: watermark + ) - let startedTracker = CallTracker() - let succeededValues = ValueTracker() + let newWatermark = UInt32(Date().timeIntervalSince1970) + 60 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 3, lastSyncTimestamp: newWatermark, isIncremental: true) + )) - await coordinator.setSyncActivityCallbacks( - onStarted: { startedTracker.markCalled() }, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService ) + #expect(outcome == .synced) - 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") + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + let since = try #require(invocations[0].since) + #expect( + since == Date(timeIntervalSince1970: Double(watermark) - 1), + "The device filter is strictly greater-than, so the window must include the watermark second" + ) - // 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") + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == newWatermark) } @Test @MainActor - func `Disconnect during resync does not double-end the resync bracket`() async throws { + func `performAdvertContactSync fullRefetch uses epoch zero and skips pruning`() async throws { let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) - let mockTransport = SimulatorMockTransport() - let session = MeshCoreSession(transport: mockTransport) - let services = try await ServiceContainer.forTesting(session: session) + let keptKey = Data(repeating: 0x11, count: 32) + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: keptKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Kept", + lastAdvertTimestamp: 1_700_000_000, + latitude: 0, + longitude: 0, + lastModified: 1_700_000_100 + ) + ) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: orphanKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Orphan", + lastAdvertTimestamp: 1_700_000_000, + latitude: 0, + longitude: 0, + lastModified: 1_700_000_100 + ) + ) - let succeededValues = ValueTracker() + let session = MockMeshCoreSession() + // Device returns only the kept contact — orphan would be pruned on since == nil. + await session.setStubbedContacts([ + MeshContact( + id: keptKey.hexString, + publicKey: keptKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Kept", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 1_800_000_000) + ) + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) - await coordinator.setSyncActivityCallbacks( - onStarted: {}, - onEnded: { succeeded in succeededValues.record(succeeded) }, - onPhaseChanged: { _ in } + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: true, + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService ) + #expect(outcome == .synced) - // Simulate resync bracket open - await coordinator.beginResyncActivity() + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect(sinceArgs[0] == Date(timeIntervalSince1970: 0)) - // Disconnect while resync bracket is open - await coordinator.onDisconnected(notificationService: services.notificationService) + // Prune-free: local-only orphan survives epoch-0 full refetch. + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil) - // 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") + // Control: since == nil prunes the orphan. + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) == nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptKey) != nil) } -} -// MARK: - Test Helpers + @Test + @MainActor + func `performAdvertContactSync returns busy when manual contact sync is active`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult( + contactsReceived: 1, + lastSyncTimestamp: UInt32(Date().timeIntervalSince1970), + isIncremental: true + ) + )) -/// Thread-safe value recorder for verifying callback arguments in tests. -final class ValueTracker: @unchecked Sendable { - private var _values: [T] = [] - private let lock = NSLock() + await coordinator.setManualContactSyncActive(true) + let busy = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(busy == .busy) + #expect( + await mockContactService.syncContactsInvocations.isEmpty, + "manual refresh must block advert delta from reaching the radio" + ) - var values: [T] { - lock.lock() - defer { lock.unlock() } - return _values + await coordinator.setManualContactSyncActive(false) + let after = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(after == .synced) + #expect(await mockContactService.syncContactsInvocations.count == 1) } - func record(_ value: T) { - lock.lock() - defer { lock.unlock() } - _values.append(value) - } -} + @Test + @MainActor + func `far-future watermark recovers with one full refetch then incremental`() async throws { + // After RTC correction, residual far-future lastmods can leave a permanent high + // watermark. Invalid stamp → prune-free epoch-0 once, then store the new max. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let referenceNow = Date() + let phoneNow = UInt32(referenceNow.timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) + // Full recovery returns a plausible max lastmod (radio clock now pinned to phone). + let recoveredWatermark = phoneNow &+ 30 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 4, lastSyncTimestamp: recoveredWatermark, isIncremental: true) + )) -/// 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? + let first = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(first == .synced) - /// Records when the activity ended callback was invoked - func recordActivityEnded() { - activityEndedTime = Date() + let afterFirst = await mockContactService.syncContactsInvocations + #expect(afterFirst.count == 1) + #expect( + afterFirst[0].since == Date(timeIntervalSince1970: 0), + "advert invalid recovery must use prune-free epoch-0, not since=nil" + ) + var device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == recoveredWatermark) + + // Second round: recovered watermark is plausible → incremental. + let nextWatermark = recoveredWatermark &+ 10 + await mockContactService.reset() + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 1, lastSyncTimestamp: nextWatermark, isIncremental: true) + )) + + let second = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(second == .synced) + + let afterSecond = await mockContactService.syncContactsInvocations + #expect(afterSecond.count == 1) + let secondSince = try #require(afterSecond[0].since) + #expect( + secondSince == Date(timeIntervalSince1970: Double(recoveredWatermark) - 1), + "after recovery the next round must be incremental, not another full fetch" + ) + device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == nextWatermark) } - /// Whether activity ended before message polling started - var activityEndedBeforeMessagePoll: Bool { - guard let ended = activityEndedTime, let poll = messagePollTime else { - return false - } - return ended < poll + @Test + @MainActor + func `far-future watermark recovery retries after a failed recovery fetch`() async throws { + // A recovery full fetch that throws must not spend the one-shot latch: the + // next round must retry the prune-free epoch-0 recovery, not fall back to the + // invalid stored watermark. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) + + // Round 1: recovery fetch throws. + await mockContactService.setStubbedSyncContactsResult( + .failure(SyncCoordinatorError.syncFailed("boom")) + ) + let first = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(first == .failed) + let afterFirst = await mockContactService.syncContactsInvocations + #expect(afterFirst.count == 1) + #expect( + afterFirst[0].since == Date(timeIntervalSince1970: 0), + "failed round must still attempt prune-free epoch-0 recovery" + ) + + // Round 2: recovery fetch succeeds. Because round 1 failed, the latch is not + // spent, so this round must retry epoch-0 recovery — not the stored watermark. + let recoveredWatermark = phoneNow &+ 30 + await mockContactService.reset() + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 2, lastSyncTimestamp: recoveredWatermark, isIncremental: true) + )) + let second = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(second == .synced) + let afterSecond = await mockContactService.syncContactsInvocations + #expect(afterSecond.count == 1) + #expect( + afterSecond[0].since == Date(timeIntervalSince1970: 0), + "a failed recovery must not spend the one-shot; retry epoch-0, not the stored far-future watermark" + ) + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == recoveredWatermark) } - // MARK: - MessagePollingServiceProtocol + @Test + @MainActor + func `advert invalid watermark recovery is prune-free and keeps local-only contacts`() async throws { + // since=nil would delete local rows absent from the device response. Advert + // recovery must use epoch-0 so a background round never prunes. + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) - func pollAllMessages() async throws -> Int { - messagePollTime = Date() - return 0 + let keptKey = Data(repeating: 0x21, count: 32) + let orphanKey = Data(repeating: 0xA9, count: 32) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: keptKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Kept", + lastAdvertTimestamp: phoneNow, + latitude: 0, + longitude: 0, + lastModified: phoneNow + ) + ) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: orphanKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Orphan", + lastAdvertTimestamp: phoneNow, + latitude: 0, + longitude: 0, + lastModified: phoneNow + ) + ) + + let session = MockMeshCoreSession() + await session.setStubbedContacts([ + MeshContact( + id: keptKey.hexString, + publicKey: keptKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Kept", + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(phoneNow)), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: TimeInterval(phoneNow &+ 10)) + ) + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService + ) + #expect(outcome == .synced) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect( + sinceArgs[0] == Date(timeIntervalSince1970: 0), + "invalid advert recovery must full-fetch with prune-free epoch-0" + ) + #expect( + try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil, + "local-only contact must survive advert invalid-watermark recovery" + ) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptKey) != nil) } - func waitForPendingHandlers(timeout: Duration) async -> Bool { - true + @Test + @MainActor + func `advert invalid watermark recovery full-fetches at most once per coordinator`() async throws { + // Residual far-future lastmods keep max(lastmod) implausible. Without a latch + // every advert debounce re-streams the whole table. One recovery, then stored stamp. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) + // Write-back stays implausible — the pathological residual-lastmod case. + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 10, lastSyncTimestamp: farFuture, isIncremental: true) + )) + + let first = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(first == .synced) + let afterFirst = await mockContactService.syncContactsInvocations + #expect(afterFirst.count == 1) + #expect(afterFirst[0].since == Date(timeIntervalSince1970: 0), "first round recovers with full fetch") + + await mockContactService.reset() + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: farFuture, isIncremental: true) + )) + + let second = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(second == .synced) + let afterSecond = await mockContactService.syncContactsInvocations + #expect(afterSecond.count == 1) + let secondSince = try #require(afterSecond[0].since) + #expect( + secondSince == Date(timeIntervalSince1970: Double(farFuture) - 1), + "second round must use the stored stamp, not another full fetch" + ) + #expect(secondSince != Date(timeIntervalSince1970: 0)) } - func startAutoFetch(radioID: UUID) async {} + @Test + @MainActor + func `watermark a few minutes ahead of phone stays incremental`() async throws { + // Radio may lead the phone by minutes before time sync settles. That is not + // an invalid watermark and must not trigger a full recovery fetch. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let minutesLead: UInt32 = 5 * 60 + let radioLead = phoneNow &+ minutesLead + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: radioLead + ) + let newWatermark = radioLead &+ 15 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 1, lastSyncTimestamp: newWatermark, isIncremental: true) + )) - func pauseAutoFetch() async {} + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(outcome == .synced) - func resumeAutoFetch() async {} + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + let since = try #require( + invocations[0].since, + "minute-scale lead must stay incremental (since non-nil), not a full recovery" + ) + #expect(since == Date(timeIntervalSince1970: Double(radioLead) - 1)) - func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) {} + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == newWatermark) + } - func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) {} + @Test + func `contactWatermarkUse marks far-future invalid and minute lead incremental`() { + let reference = Date(timeIntervalSince1970: 1_800_000_000) + let refSeconds = UInt32(reference.timeIntervalSince1970) + let minuteLead = refSeconds &+ (5 * 60) + let farFuture = refSeconds &+ UInt32(30 * 24 * 60 * 60) + + #expect(SyncCoordinator.contactWatermarkUse(fromLastContactSync: nil, referenceNow: reference) == .none) + #expect(SyncCoordinator.contactWatermarkUse(fromLastContactSync: 0, referenceNow: reference) == .none) + #expect( + SyncCoordinator.contactWatermarkUse(fromLastContactSync: minuteLead, referenceNow: reference) + == .incremental(minuteLead) + ) + #expect( + SyncCoordinator.contactWatermarkUse(fromLastContactSync: farFuture, referenceNow: reference) + == .invalid(stored: farFuture) + ) + } - func setSignedMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} + @MainActor + @Test(arguments: [false, true]) + func `performAdvertContactSync with zero watermark does not sync`(fullRefetch: Bool) async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 0 + ) - func setCLIMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?) async -> Void) {} -} + let newWatermark: UInt32 = 1_800_000_000 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 2, lastSyncTimestamp: newWatermark, isIncremental: true) + )) -/// Mock contact service that delays and signals when sync has started -actor DelayingContactService: ContactServiceProtocol { - private var continuation: CheckedContinuation? + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: fullRefetch, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(outcome == .notReady) - /// Wait to be signaled that contacts sync has started - func waitForSyncStart() async { - await withCheckedContinuation { continuation in - self.continuation = continuation - } - } + #expect( + await mockContactService.syncContactsInvocations.isEmpty, + "No advert sync may run before the first pruning full sync succeeds" + ) - /// Allow the sync to complete - func completeSync() { - continuation?.resume() - continuation = nil + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect( + device.lastContactSync == 0, + "Writing a watermark here would suppress the one-time pruning full sync forever" + ) } - func syncContacts(radioID: UUID, since: Date?) async throws -> ContactSyncResult { - // Signal that sync has started, then wait to be resumed + @Test + @MainActor + func `Advert sync runs after a full sync that found no contacts`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 0 + ) + + // An empty radio has no contact to stamp, so the zero sentinel survives the full sync. + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) + )) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == 0) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(outcome == .synced, "The first auto-added contact must not wait for another full sync") + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 2) + #expect( + invocations[1].since == Date(timeIntervalSince1970: 0), + "With no watermark to resume from, the delta round fetches prune-free from epoch zero" + ) + } + + @Test + @MainActor + func `Full sync still prunes after an advert sync attempt with no watermark`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 0 + ) + + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 2, lastSyncTimestamp: 1_800_000_000, isIncremental: true) + )) + + _ = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + #expect( + invocations[0].since == nil, + "The first full sync after a failed connect sync must still prune" + ) + } + + @Test + @MainActor + func `performAdvertContactSync returns false while sync claimed`() async throws { + let coordinator = SyncCoordinator() + let delayingContactService = DelayingContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let startedTracker = CallTracker() + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in }, + onPhaseChanged: { _ in } + ) + + let firstSyncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + } + + try await waitUntil("First sync should have started") { + startedTracker.callCount >= 1 + } + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: MockContactService() + ) + #expect(outcome == .busy, "A collision with another sync is not a failed exchange") + + await delayingContactService.completeSync() + firstSyncTask.cancel() + } + + @Test + @MainActor + func `Channel retry waits for an active advert contact sync instead of skipping`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let mockChannelService = MockChannelService() + let testDeviceID = UUID() + let retryIndices: [UInt8] = [3, 5] + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let retryFinished = CallTracker() + let retryTask = Task { + let result = await coordinator.retryChannels( + radioID: testDeviceID, + channelService: mockChannelService, + indices: retryIndices + ) + retryFinished.markCalled() + return result + } + + try await Task.sleep(for: .milliseconds(200)) + #expect( + !retryFinished.wasCalled, + "Channel retry must wait while an advert contact sync holds the claim" + ) + #expect(await mockChannelService.retryInvocations.isEmpty) + + await gatedContactService.release() + + #expect(await advertTask.value == .synced, "Advert contact sync should complete once released") + let result = await retryTask.value + #expect(result.errors.isEmpty, "Channel retry must run for real after the advert sync releases") + #expect(await mockChannelService.retryInvocations.map(\.indices) == [retryIndices]) + } + + @Test + @MainActor + func `claimManualContactSync is atomic with the wait so advert cannot claim in the gap`() async throws { + // Separate wait and setManual hops leave a gap where a delta can pass both + // guards. One method waits then sets manual without yielding. + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let claimTask = Task { + try await coordinator.claimManualContactSync() + } + + try await Task.sleep(for: .milliseconds(100)) + // Still waiting on the advert claim — must not have set manual yet in a way + // that would be racy; release advert so the claim can complete. + await gatedContactService.release() + #expect(await advertTask.value == .synced) + try await claimTask.value + + let racingAdvert = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: MockContactService() + ) + #expect( + racingAdvert == .busy, + "After claimManualContactSync returns, manual flag must already be set" + ) + + await coordinator.setManualContactSyncActive(false) + } + + @Test + @MainActor + func `waitForAdvertContactSync throws when cancelled`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let waitTask = Task { + try await coordinator.waitForAdvertContactSync() + } + try await Task.sleep(for: .milliseconds(50)) + waitTask.cancel() + + var threwCancellation = false + do { + try await waitTask.value + } catch is CancellationError { + threwCancellation = true + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + #expect(threwCancellation, "Cancelled wait must throw CancellationError, not hang") + + await gatedContactService.release() + _ = await advertTask.value + } + + @Test + @MainActor + func `waitForAdvertContactSync throws when the bound is reached`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + var timedOut = false + do { + try await coordinator.waitForAdvertContactSync(timeout: .milliseconds(40)) + } catch let error as SyncCoordinatorError { + if case let .syncFailed(message) = error { + timedOut = message == SyncCoordinator.advertContactSyncWaitTimedOutMessage + } + } + #expect(timedOut, "Wait must surface a timeout error rather than hang or silent-skip") + + await gatedContactService.release() + _ = await advertTask.value + } + + @Test + @MainActor + func `onDisconnected resumes advert claim waiters`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let waitTask = Task { + try await coordinator.waitForAdvertContactSync(timeout: .seconds(5)) + } + try await Task.sleep(for: .milliseconds(50)) + + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Waiter must resume when the connection drops, not sit until the advert defer. + try await waitTask.value + + await gatedContactService.release() + _ = await advertTask.value + } + + @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: - Capacity-capped contact staleness at connect + + @Test + @MainActor + func `At-capacity connect forces full contact sync and prunes the missing non-favourite`() async throws { + // Valid watermark would normally drive incremental sync, which never prunes. + // localCount == maxContacts forces a full (pruning) fetch instead. + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let maxContacts: UInt16 = 3 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: maxContacts, + lastContactSync: watermark + ) + + let keptA = Data(repeating: 0x11, count: 32) + let keptB = Data(repeating: 0x22, count: 32) + let evicted = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: keptA, name: "KeptA")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: keptB, name: "KeptB")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: evicted, name: "Evicted")) + + let session = MockMeshCoreSession() + // Radio table is full and missing the evicted key (disconnected eviction). + await session.setStubbedContacts([ + meshContact(key: keptA, name: "KeptA"), + meshContact(key: keptB, name: "KeptB"), + meshContact(key: Data(repeating: 0x33, count: 32), name: "Newcomer") + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect(sinceArgs[0] == nil, "At-capacity connect must force since == nil (full prune path)") + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: evicted) == nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptA) != nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptB) != nil) + } + + @Test + @MainActor + func `Below-capacity connect keeps incremental contact sync and does not prune`() async throws { + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 100, + lastContactSync: watermark + ) + + let kept = Data(repeating: 0x11, count: 32) + let orphan = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: kept, name: "Kept")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphan, name: "Orphan")) + + let session = MockMeshCoreSession() + // Incremental batch omits the orphan; below capacity must not prune it. + await session.setStubbedContacts([ + meshContact(key: kept, name: "Kept") + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + let since = try #require(sinceArgs[0], "Below-capacity connect must pass a non-nil since") + #expect(since == Date(timeIntervalSince1970: Double(watermark) - 1)) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphan) != nil) + } + + @Test + @MainActor + func `maxContacts zero does not force capacity full contact sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 0, + lastContactSync: watermark + ) + // Even with local contacts present, maxContacts == 0 must not force full. + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: Data(repeating: 0x11, count: 32), name: "Any") + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + #expect(invocations[0].since != nil, "maxContacts == 0 must not force since == nil") + } + + @Test + @MainActor + func `Contact count read failure falls back to incremental and keeps connection usable`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let store = MockPersistenceStore() + try await store.saveDevice( + DeviceDTO.testDevice( + id: testDeviceID, + radioID: testDeviceID, + maxContacts: 3, + lastContactSync: watermark + ) + ) + // Count would be at capacity if the read succeeded; force the read to fail. + for byte: UInt8 in [0x11, 0x22, 0x33] { + try await store.saveContact( + ContactDTO( + id: UUID(), + radioID: testDeviceID, + publicKey: Data(repeating: byte, count: 32), + name: "C\(byte)", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + ) + } + await store.setStubbedFetchContactPublicKeysError( + NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "count read failed"]) + ) + + let result = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: store, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + #expect(result.isConnectionUsable) + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + #expect( + invocations[0].since != nil, + "Count-read failure must degrade to incremental (today's behavior)" + ) + } + + @Test + @MainActor + func `Full sync prune skips a truncated stream the ratio floor would have pruned`() async throws { + // Device reports 4 contacts but the stream ends after 3 (favourite dropped). + // received < reportedTotal, so the prune skips and every local row survives. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let favouriteKey = Data(repeating: 0xAA, count: 32) + let otherKeys = [ + Data(repeating: 0x11, count: 32), + Data(repeating: 0x22, count: 32), + Data(repeating: 0x33, count: 32) + ] + let (favouriteID, _) = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: favouriteKey, name: "Favourite", flags: ContactFlags.favorite.rawValue) + ) + // ContactFrame maps flags bit 0 to isFavorite; keep a message under the favourite. + let favourite = try #require(await dataStore.fetchContact(id: favouriteID)) + #expect(favourite.isFavorite) + try await dataStore.saveMessage(MessageDTO(from: Message( + radioID: testDeviceID, + contactID: favouriteID, + text: "keep me", + timestamp: 1_700_000_000 + ))) + for (index, key) in otherKeys.enumerated() { + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: key, name: "Other\(index)") + ) + } + + let session = MockMeshCoreSession() + // Truncated: 3 of the 4 the device reports (the favourite frame was dropped). + await session.setStubbedContacts(otherKeys.enumerated().map { index, key in + meshContact(key: key, name: "Other\(index)") + }) + await session.setStubbedReportedTotal(4) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: favouriteKey) != nil) + let messages = try await dataStore.fetchMessages(contactID: favouriteID, limit: 10, offset: 0) + #expect(messages.count == 1) + #expect(messages.first?.text == "keep me") + for key in otherKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `Full sync prune removes a favourite absent from a complete device set`() async throws { + // Device returns a complete list that omits one favourite the user deleted on + // the radio. received (3) == reported (3), so the snapshot is complete and the + // prune runs; the favourite has no favourite-only exemption. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let favouriteKey = Data(repeating: 0xAA, count: 32) + let keptKeys = [ + Data(repeating: 0x11, count: 32), + Data(repeating: 0x22, count: 32), + Data(repeating: 0x33, count: 32) + ] + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: favouriteKey, name: "Favourite", flags: ContactFlags.favorite.rawValue) + ) + for (index, key) in keptKeys.enumerated() { + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: key, name: "Kept\(index)") + ) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts(keptKeys.enumerated().map { index, key in + meshContact(key: key, name: "Kept\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: favouriteKey) == nil) + for key in keptKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `Full sync prune removes orphans when the device genuinely shrank below half`() async throws { + // Complete device reply with one contact: received == reportedTotal, so the + // three stale local rows are pruned. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let survivorKey = Data(repeating: 0x11, count: 32) + let staleKeys = [ + Data(repeating: 0xAA, count: 32), + Data(repeating: 0x22, count: 32), + Data(repeating: 0x33, count: 32) + ] + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: survivorKey, name: "Survivor")) + for (index, key) in staleKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Stale\(index)")) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(key: survivorKey, name: "Survivor")]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: survivorKey) != nil) + for key in staleKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) == nil) + } + } + + @Test + @MainActor + func `Full sync prune preserves the local V-contact omitted from a complete stream`() async throws { + // ZephCore omits the V-contact while clock-deferred, so a complete stream can + // exclude it. It must survive the prune; a genuine orphan alongside it must not. + let testDeviceID = UUID() + let selfPublicKey = Data(repeating: 0x01, count: 32) // DeviceDTO.testDevice default + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let vContactKey = try #require(VContactIdentity.publicKey(forSelfPublicKey: selfPublicKey)) + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: vContactKey, name: "V-Contact")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphanKey, name: "Orphan")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + // Complete stream of the two real contacts; V-contact and orphan both absent. + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: vContactKey) != nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) == nil) + for key in realKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `V-contact in the last slot keeps the connect incremental, not at capacity`() async throws { + // maxContacts - 1 real contacts plus the local V-contact fill every slot, but + // the V-contact is virtual. Excluding it keeps the radio below capacity, so a + // valid watermark still drives an incremental (non-pruning) sync. + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let selfPublicKey = Data(repeating: 0x01, count: 32) // DeviceDTO.testDevice default + let watermark: UInt32 = 1_704_067_200 + let maxContacts: UInt16 = 3 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: maxContacts, + lastContactSync: watermark + ) + + let vContactKey = try #require(VContactIdentity.publicKey(forSelfPublicKey: selfPublicKey)) + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: vContactKey, name: "V-Contact")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect(sinceArgs[0] != nil, "V-contact in the last slot must not trip at-capacity; sync stays incremental") + } + + @Test + @MainActor + func `Full sync prune skips when the device sent no contact total`() async throws { + // A reply with no contactsStart header leaves the total unknown, so the + // snapshot cannot be proven complete. The prune must skip even though the + // received set would otherwise look like the whole table. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphanKey, name: "Orphan")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + await session.setStubbedReportsNoTotal(true) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect( + try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil, + "Prune must skip when the device reports no total, so the orphan survives" + ) + for key in realKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `Full sync prune skips when the self public key is the wrong length`() async throws { + // A device row with a malformed (non-32-byte) self key cannot identify the + // V-contact, so the prune must skip rather than risk deleting it. A local + // orphan survives even though the device set is complete. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + try await dataStore.saveDevice(DeviceDTO.testDevice( + id: testDeviceID, + radioID: testDeviceID, + publicKey: Data(repeating: 0x01, count: 8), + maxContacts: 4, + lastContactSync: 1_704_067_200 + )) + + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphanKey, name: "Orphan")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + // Complete stream of the two real contacts; received == reportedTotal. + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect( + try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil, + "Prune must skip when the self key is malformed, so the orphan survives" + ) + for key in realKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } +} + +// 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) + } +} + +/// 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? + + /// 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 + } + + // MARK: - MessagePollingServiceProtocol + + func pollAllMessages() async throws -> Int { + messagePollTime = Date() + return 0 + } + + func waitForPendingHandlers(timeout: Duration) async -> Bool { + true + } + + func startAutoFetch(radioID: UUID) async {} + + func pauseAutoFetch() async {} + + func resumeAutoFetch() async {} + + func setContactMessageHandler(_ handler: @escaping @Sendable (ContactMessage, ContactDTO?, DeliveryContext) async -> Void) {} + + func setChannelMessageHandler(_ handler: @escaping @Sendable (ChannelMessage, ChannelDTO?, DeliveryContext) async -> Void) {} + + func setSignedMessageHandler(_ 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 @@ -862,6 +2298,39 @@ actor DelayingContactService: ContactServiceProtocol { } } +/// Mock contact service that suspends inside `syncContacts` until the test releases it. +/// Lets a test hold an advert delta sync open while another sync path runs. +private actor GatedContactService: ContactServiceProtocol { + private var hasStarted = false + private var startWaiters: [CheckedContinuation] = [] + private var gate: CheckedContinuation? + private var isReleased = false + + /// Waits until `syncContacts` has entered and is holding at the gate. + func waitForSyncStart() async { + if hasStarted { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + /// Lets the held `syncContacts` call return. + func release() { + isReleased = true + gate?.resume() + gate = nil + } + + func syncContacts(radioID _: UUID, since _: Date?) async throws -> ContactSyncResult { + hasStarted = true + while !startWaiters.isEmpty { + startWaiters.removeFirst().resume() + } + if !isReleased { + await withCheckedContinuation { gate = $0 } + } + return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: true) + } +} + /// Mock channel service that blocks in syncChannels until cancelled. actor DelayingChannelService: ChannelServiceProtocol { private var hasStarted = false diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift index 76fa61395..159306061 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift @@ -4,11 +4,9 @@ import Foundation import ObjectiveC import Testing -/// A transient `didFailToConnect` during `.autoReconnecting` (the common -/// backgrounded case: `CBError.encryptionTimedOut`) must re-issue the pending -/// connect and stay in `.autoReconnecting` rather than abandon the OS pending -/// connection. Only a definitive auth code, or an exhausted retry budget, -/// tears the episode down and notifies loss. +/// `didFailToConnect` during `.autoReconnecting` re-issues the pending +/// connect and stays in the episode. Tear-down is a definitive auth code, +/// or an exhausted budget while the app is confirmed active. @Suite("BLEStateMachine auto-reconnect connect retry") struct BLEStateMachineAutoReconnectRetryTests { private var encryptionTimedOut: NSError { @@ -52,6 +50,7 @@ struct BLEStateMachineAutoReconnectRetryTests { await sm.injectTestCentralManager() let peripheral = makeLeakedRetryPeripheral() let recorder = await makeRecorder(on: sm) + await sm.appDidBecomeActive() await sm.primeAutoReconnecting(peripheral: peripheral) // Every failure below the cap re-arms silently and stays in the episode. @@ -61,7 +60,7 @@ struct BLEStateMachineAutoReconnectRetryTests { #expect(await sm.currentPhase.name == "autoReconnecting") #expect(recorder.events.isEmpty) - // The failure that reaches the cap gives up. + // The failure that reaches the cap tears the episode down. await sm.handleDidFailToConnect(peripheral, error: encryptionTimedOut) #expect(await sm.currentPhase.name == "idle") @@ -79,6 +78,7 @@ struct BLEStateMachineAutoReconnectRetryTests { await sm.injectTestCentralManager() let peripheral = makeLeakedRetryPeripheral() let recorder = await makeRecorder(on: sm) + await sm.appDidBecomeActive() await sm.primeAutoReconnecting(peripheral: peripheral) for _ in 1.. (BLEStateMachine, FringeTestPeripheral, FringeDisconnectionRecorder) { FringeTestPeripheral.reset() let sm = BLEStateMachine() @@ -31,30 +28,30 @@ struct BLEStateMachineFringeEncryptionGraceTests { if let bondVerified { await sm.recordBondVerification(deviceID: FringeTestPeripheral.uuid, at: bondVerified) } + if appActive { + await sm.appDidBecomeActive() + } await sm.primeFringeAutoReconnecting(peripheral: peripheral) return (sm, peripheral, recorder) } - // MARK: - Fringe replay (the regression test for this bug) + // MARK: - Fringe replay + /// A recently verified encryption-timeout majority re-issues connect + /// instead of tearing down. @Test - func `exhausted encryption-timeout budget with a recently verified bond stays transient`() async { + func `out-of-range encryption timeouts with a live bond keep auto-reconnecting`() async { let (sm, peripheral, recorder) = await makeMachine(bondVerified: Date().addingTimeInterval(-60)) - for _ in 1.. ReconnectPolicy { var policy = ReconnectPolicy() if let bondVerified { @@ -225,11 +227,22 @@ struct ReconnectPolicyConnectFailureTests { private func exhaustBudget( _ policy: inout ReconnectPolicy, error: NSError, - now: Date = Date() + now: Date = Date(), + appActive: Bool = true ) -> ReconnectPolicy.ConnectFailureDecision { - var last = policy.resolveConnectFailure(deviceID: deviceID, error: error, now: now) + var last = policy.resolveConnectFailure( + deviceID: deviceID, + error: error, + now: now, + appActive: appActive + ) for _ in 1.. ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "forget \(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 + ) +} diff --git a/MC1Tests/AppState/NavigationCoordinatorTests.swift b/MC1Tests/AppState/NavigationCoordinatorTests.swift index 7eac39f83..5ae2c9859 100644 --- a/MC1Tests/AppState/NavigationCoordinatorTests.swift +++ b/MC1Tests/AppState/NavigationCoordinatorTests.swift @@ -27,6 +27,7 @@ struct NavigationCoordinatorNotificationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -375,6 +376,7 @@ struct NavigationCoordinatorPendingLinkTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/AppState/NavigationStateTests.swift b/MC1Tests/AppState/NavigationStateTests.swift index 8b73bcfc5..9f7917549 100644 --- a/MC1Tests/AppState/NavigationStateTests.swift +++ b/MC1Tests/AppState/NavigationStateTests.swift @@ -25,6 +25,7 @@ struct NavigationStateTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Intents/EntityIdentityTests.swift b/MC1Tests/Intents/EntityIdentityTests.swift index b794c9925..0c635c8ef 100644 --- a/MC1Tests/Intents/EntityIdentityTests.swift +++ b/MC1Tests/Intents/EntityIdentityTests.swift @@ -34,6 +34,7 @@ struct EntityIdentityTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -215,6 +216,7 @@ struct EntityQueryScopingTests { 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, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, lastMessageDate: nil, unreadCount: 0 ) diff --git a/MC1Tests/Intents/IntentErrorLocalizationTests.swift b/MC1Tests/Intents/IntentErrorLocalizationTests.swift index 8f26fabe6..78213ca82 100644 --- a/MC1Tests/Intents/IntentErrorLocalizationTests.swift +++ b/MC1Tests/Intents/IntentErrorLocalizationTests.swift @@ -6,7 +6,7 @@ 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 10 locales. +/// the raw key resolves to real copy in all 11 locales. struct IntentErrorLocalizationTests { /// The localizable table backing the `error.intent.*` keys. private static let table = "Localizable" @@ -23,9 +23,9 @@ struct IntentErrorLocalizationTests { (.advertFailed, "error.advertisement.sendFailed", L10n.Localizable.Error.Advertisement.sendFailed), ] - /// The 10 shipped locales. A key missing from any one would fall back to the + /// The 11 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", "it", "nl", "pl", "ru", "uk", "zh-Hans"] + private static let locales = ["de", "en", "es", "fr", "it", "nl", "pl", "pt", "ru", "uk", "zh-Hans"] // MARK: - Generated accessor agreement @@ -56,7 +56,7 @@ struct IntentErrorLocalizationTests { #expect(resolved?.isEmpty == false) } - // MARK: - No raw-key fallback across all 10 locales + // MARK: - No raw-key fallback across all 11 locales @Test func `every intent key resolves in every locale`() throws { for locale in Self.locales { diff --git a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift index cf70f79ed..5659f8b1c 100644 --- a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift +++ b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift @@ -5,9 +5,9 @@ import Testing /// App Intents resolves every static metadata literal (`LocalizedStringResource` /// title, description, parameter title, short title, and `ParameterSummary`) /// against the `Tools` table in Siri's locale, so a key missing from any one of -/// the 10 shipped `.lproj` bundles surfaces as the raw key in that language. These +/// the 11 shipped `.lproj` bundles surfaces as the raw key in that language. These /// tests pin every metadata key the intents reference and confirm each resolves -/// to real copy in all 10 locales, never the raw-key fallback. +/// to real copy in all 11 locales, never the raw-key fallback. struct IntentMetadataLocalizationTests { /// The table backing every App Intents static metadata literal. private static let table = "Tools" @@ -36,9 +36,9 @@ struct IntentMetadataLocalizationTests { "intent.advert.reach.flood", ] - /// The 10 shipped locales. A key missing from any one falls back to the raw + /// The 11 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", "it", "nl", "pl", "ru", "uk", "zh-Hans"] + private static let locales = ["de", "en", "es", "fr", "it", "nl", "pl", "pt", "ru", "uk", "zh-Hans"] @Test func `every metadata key resolves in every locale`() throws { for locale in Self.locales { diff --git a/MC1Tests/Intents/SendMessageIntentTests.swift b/MC1Tests/Intents/SendMessageIntentTests.swift index 3532d986b..f41b07032 100644 --- a/MC1Tests/Intents/SendMessageIntentTests.swift +++ b/MC1Tests/Intents/SendMessageIntentTests.swift @@ -26,6 +26,7 @@ struct SendMessageIntentTests { 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, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, lastMessageDate: nil, unreadCount: 0 ) @@ -63,7 +64,7 @@ struct SendMessageIntentTests { let container = try PersistenceStore.createContainer(inMemory: true) let services = ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: container, + dataStore: PersistenceStore(modelContainer: container), radioID: Self.radioID ) appState.connectionManager.setTestState( diff --git a/MC1Tests/Models/ContactOCVTests.swift b/MC1Tests/Models/ContactOCVTests.swift index f9abc7ecd..dbe7806f5 100644 --- a/MC1Tests/Models/ContactOCVTests.swift +++ b/MC1Tests/Models/ContactOCVTests.swift @@ -19,6 +19,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -47,6 +48,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -78,6 +80,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -106,6 +109,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Models/ConversationFilteringTests.swift b/MC1Tests/Models/ConversationFilteringTests.swift index ce04271ea..d011c5f0c 100644 --- a/MC1Tests/Models/ConversationFilteringTests.swift +++ b/MC1Tests/Models/ConversationFilteringTests.swift @@ -25,6 +25,7 @@ struct ConversationFilteringTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: isMuted, diff --git a/MC1Tests/Services/ImageURLDetectorTests.swift b/MC1Tests/Services/ImageURLDetectorTests.swift index 14dade189..78174c460 100644 --- a/MC1Tests/Services/ImageURLDetectorTests.swift +++ b/MC1Tests/Services/ImageURLDetectorTests.swift @@ -2,6 +2,7 @@ import Foundation @testable import MC1 @testable import MC1Services import Testing +import UIKit struct ImageURLDetectorTests { // MARK: - Direct Image URL Detection @@ -76,6 +77,23 @@ struct ImageURLDetectorTests { #expect(!ImageURLDetector.isGIFData(Data())) } + // MARK: - Downsample + + @Test + func `downsampledImage honors maxPixelSize`() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64), format: format) + let source = renderer.image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) + } + let data = try #require(source.pngData()) + let downsampled = ImageURLDetector.downsampledImage(from: data, maxPixelSize: 32) + let image = try #require(downsampled) + #expect(max(image.size.width, image.size.height) <= 32) + } + // MARK: - Giphy URL Resolution @Test diff --git a/MC1Tests/Services/InlineImagePrefetcherTests.swift b/MC1Tests/Services/InlineImagePrefetcherTests.swift index 5c934ec12..a1abc1831 100644 --- a/MC1Tests/Services/InlineImagePrefetcherTests.swift +++ b/MC1Tests/Services/InlineImagePrefetcherTests.swift @@ -288,6 +288,23 @@ private actor StubDataStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } @@ -328,6 +345,11 @@ private actor StubDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} @@ -580,7 +602,7 @@ private actor StubDataStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -588,10 +610,17 @@ private actor StubDataStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index 5a8a09eb5..46c8d70f9 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -242,6 +242,23 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } @@ -283,6 +300,11 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} @@ -551,7 +573,7 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -559,10 +581,17 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } /// Channel Message Deletion func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} diff --git a/MC1Tests/Services/TranslationLanguageResolverTests.swift b/MC1Tests/Services/TranslationLanguageResolverTests.swift new file mode 100644 index 000000000..dda4d61d6 --- /dev/null +++ b/MC1Tests/Services/TranslationLanguageResolverTests.swift @@ -0,0 +1,87 @@ +import Foundation +@testable import MC1 +import Testing + +@Suite("TranslationLanguageResolver") +struct TranslationLanguageResolverTests { + private let supported = [ + Locale.Language(identifier: "ja-JP"), + Locale.Language(identifier: "en-US"), + Locale.Language(identifier: "en-GB"), + Locale.Language(identifier: "zh-Hans-CN"), + Locale.Language(identifier: "zh-Hant-TW"), + Locale.Language(identifier: "de-DE") + ] + + @Test + func `compact Japanese maps to the supported ja-JP pack`() { + let resolved = TranslationLanguageResolver.resolve( + "ja", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.region == Locale.Region("JP")) + } + + @Test + func `simplified Chinese prefers Hans over Hant`() { + let resolved = TranslationLanguageResolver.resolve( + "zh-Hans", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.script == Locale.Language(identifier: "zh-Hans-CN").script) + } + + @Test + func `traditional Chinese maps to Hant even when Hans is listed first`() { + let resolved = TranslationLanguageResolver.resolve( + "zh-Hant", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.script == Locale.Language(identifier: "zh-Hant-TW").script) + #expect(resolved.region == Locale.Region("TW")) + } + + @Test + func `compact Chinese prefers the locale script`() { + let resolved = TranslationLanguageResolver.resolve( + "zh", + from: supported, + preferring: Locale(identifier: "zh-Hant-TW") + ) + #expect(resolved.script == Locale.Language(identifier: "zh-Hant-TW").script) + #expect(resolved.region == Locale.Region("TW")) + } + + @Test + func `compact English prefers the locale region`() { + let resolved = TranslationLanguageResolver.resolve( + "en", + from: supported, + preferring: Locale(identifier: "en-GB") + ) + #expect(resolved.region == Locale.Region("GB")) + } + + @Test + func `specified British English stays GB when preferring US`() { + let resolved = TranslationLanguageResolver.resolve( + "en-GB", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.region == Locale.Region("GB")) + } + + @Test + func `unknown language is returned unchanged`() { + let resolved = TranslationLanguageResolver.resolve( + "xx", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.languageCode == Locale.Language(identifier: "xx").languageCode) + } +} diff --git a/MC1Tests/Services/TranslationSessionConfigurationTests.swift b/MC1Tests/Services/TranslationSessionConfigurationTests.swift new file mode 100644 index 000000000..fab5b90cd --- /dev/null +++ b/MC1Tests/Services/TranslationSessionConfigurationTests.swift @@ -0,0 +1,26 @@ +import Foundation +@testable import MC1 +import Testing +import Translation + +@Suite("TranslationSession.Configuration replacing") +struct TranslationSessionConfigurationTests { + @Test + func `same pair invalidates so Equatable changes`() { + let source = Locale.Language(identifier: "de") + let target = Locale.Language(identifier: "en") + let first = TranslationSession.Configuration.replacing( + nil, + source: source, + target: target + ) + let same = TranslationSession.Configuration.replacing( + first, + source: source, + target: target + ) + #expect(same.source == first.source) + #expect(same.target == first.target) + #expect(same != first) + } +} diff --git a/MC1Tests/Services/TranslationSessionLauncherTests.swift b/MC1Tests/Services/TranslationSessionLauncherTests.swift new file mode 100644 index 000000000..4d56a1fa8 --- /dev/null +++ b/MC1Tests/Services/TranslationSessionLauncherTests.swift @@ -0,0 +1,104 @@ +import Foundation +@testable import MC1 +import Testing +import Translation + +@Suite("TranslationSessionLauncher") +@MainActor +struct TranslationSessionLauncherTests { + @Test + func `finished installed perform returns no configuration`() async { + guard #available(iOS 26.0, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in .finished } + #expect(configuration == nil) + } + + @Test + func `needsDownload falls back to a configuration for the pair`() async { + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "ja-JP", + targetLanguageCode: "en-US", + generation: 1 + ) + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in .needsDownload } + #expect(configuration != nil) + } + + @Test + func `needsDownload then finished on the second installed session returns no configuration`() async { + guard #available(iOS 26.4, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + var performCount = 0 + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in + performCount += 1 + return performCount == 1 ? .needsDownload : .finished + } + #expect(configuration == nil) + #expect(performCount == 2) + } + + @Test + func `wrapping remaps notInstalled`() { + guard #available(iOS 26.0, *) else { return } + let remapped = MessageTranslationNeedsDownloadError.wrapping(TranslationError.notInstalled) + #expect(remapped is MessageTranslationNeedsDownloadError) + } + + @Test + func `presentSystemOverlay returns no configuration`() async { + guard #available(iOS 26.0, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in .presentSystemOverlay(text: "Guten Morgen") } + #expect(configuration == nil) + } + + @Test + func `presentSystemOverlay on the first installed session does not try the second`() async { + guard #available(iOS 26.4, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + var performCount = 0 + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in + performCount += 1 + return .presentSystemOverlay(text: "Guten Morgen") + } + #expect(configuration == nil) + #expect(performCount == 1) + } +} diff --git a/MC1Tests/State/ChatPrewarmRefresherTests.swift b/MC1Tests/State/ChatPrewarmRefresherTests.swift index cd5b19701..984aa71c5 100644 --- a/MC1Tests/State/ChatPrewarmRefresherTests.swift +++ b/MC1Tests/State/ChatPrewarmRefresherTests.swift @@ -129,6 +129,7 @@ struct ChatPrewarmRefresherTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -183,9 +184,9 @@ struct ChatPrewarmRefresherTests { viewModel.applyEnvInputs(.default) switch conversation { case let .dm(contact): - await viewModel.primeInitialMessages(for: contact) + await viewModel.primeInitialMessages(for: contact, populateMode: .replace) case let .channel(channel): - await viewModel.primeInitialChannelMessages(for: channel) + await viewModel.primeInitialChannelMessages(for: channel, populateMode: .replace) } } diff --git a/MC1Tests/State/ChatTimelineFreshnessTests.swift b/MC1Tests/State/ChatTimelineFreshnessTests.swift index d1b0d2a90..d01ee8782 100644 --- a/MC1Tests/State/ChatTimelineFreshnessTests.swift +++ b/MC1Tests/State/ChatTimelineFreshnessTests.swift @@ -15,7 +15,7 @@ import Testing /// `AppState.ensureChatPrewarmRefresher()` wiring end to end; the unit tests /// in ChatPrewarmRefresherTests inject test hooks and cannot catch a /// production-wiring failure. -@Suite("ChatTimelineFreshness", .serialized) +@Suite("ChatTimelineFreshness", .serialized, .isolatedIncomingAvatarJPEGStore) @MainActor struct ChatTimelineFreshnessTests { // MARK: - Fixtures @@ -39,6 +39,7 @@ struct ChatTimelineFreshnessTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -90,7 +91,13 @@ struct ChatTimelineFreshnessTests { ) } - private func makeChannelMessage(radioID: UUID, channelIndex: UInt8 = 0, timestamp: UInt32, text: String) -> MessageDTO { + private func makeChannelMessage( + radioID: UUID, + channelIndex: UInt8 = 0, + timestamp: UInt32, + text: String, + senderNodeName: String = "Sender" + ) -> MessageDTO { MessageDTO( id: UUID(), radioID: radioID, @@ -106,7 +113,7 @@ struct ChatTimelineFreshnessTests { pathLength: 0, snr: nil, senderKeyPrefix: nil, - senderNodeName: "Sender", + senderNodeName: senderNodeName, isRead: false, replyToID: nil, roundTripTime: nil, @@ -148,9 +155,9 @@ struct ChatTimelineFreshnessTests { dataStore: dataStore, bake: bake, envInputs: .default, - senderTables: .empty, - reactions: nil, - postApply: nil + senderTables: { .empty }, + postApply: nil, + anchorSortDate: nil ) guard case .loaded = outcome else { Issue.record("populate outcome was \(outcome), expected .loaded") @@ -162,6 +169,73 @@ struct ChatTimelineFreshnessTests { #expect(dividerItemID == newMessage.id) } + @Test + func `populate bakeAll reads sender tables after the fetch await`() async throws { + let dataStore = try makeStore() + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + let radioID = UUID() + let channel = makeChannel(radioID: radioID) + try await dataStore.saveChannel(channel) + + let alice = makeChannelMessage( + radioID: radioID, + timestamp: 1000, + text: "hi", + senderNodeName: "Alice" + ) + try await dataStore.saveMessage(alice) + + let contactID = UUID() + let stale = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: contactID, + imageRevision: 1 + ) + let live = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: contactID, + imageRevision: 2 + ) + var tables = ChatSenderTables( + contacts: [], + nicknamesByLoweredName: [:], + incomingAvatars: ["alice": stale] + ) + + let coordinator = registry.coordinator(for: .channel(radioID: radioID, channelIndex: channel.index)) + let owner = WriterOwner() + let writer = try #require(coordinator.bindWriter(owner: owner, role: .prime)) + let bake = ChatMessageBakeState() + + coordinator.testPopulateAfterFetchHook = { + tables = ChatSenderTables( + contacts: [], + nicknamesByLoweredName: [:], + incomingAvatars: ["alice": live] + ) + } + defer { coordinator.testPopulateAfterFetchHook = nil } + + let outcome = await ChatTimelinePopulator.populate( + .channel(channel), + writer: writer, + dataStore: dataStore, + bake: bake, + envInputs: .default, + senderTables: { tables }, + postApply: nil, + anchorSortDate: nil + ) + guard case .loaded = outcome else { + Issue.record("populate outcome was \(outcome), expected .loaded") + return + } + + await coordinator.buildItemsTask?.value + let item = coordinator.renderState.items.first { $0.id == alice.id } + #expect(item?.envelope.incomingAvatar == live) + } + // MARK: - Production hook chain, arrival-time refresh @Test @@ -288,7 +362,7 @@ struct ChatTimelineFreshnessTests { conversation: .dm(contact) ) viewModel.applyEnvInputs(.default) - await viewModel.primeInitialMessages(for: contact) + await viewModel.primeInitialMessages(for: contact, populateMode: .replace) let id = ChatConversationID.dm(radioID: radioID, contactID: contact.id) let coordinator = try #require(registry.existingCoordinator(for: id)) diff --git a/MC1Tests/State/ChatTimelinePrimerTests.swift b/MC1Tests/State/ChatTimelinePrimerTests.swift index 62a2241aa..993f992ce 100644 --- a/MC1Tests/State/ChatTimelinePrimerTests.swift +++ b/MC1Tests/State/ChatTimelinePrimerTests.swift @@ -8,7 +8,7 @@ import Testing /// `ChatCoordinator.bindWriter` seam: stale post-resume writes drop, binds deny /// under an open interactive owner, and channel primes resolve senders through /// contacts. -@Suite("ChatTimelinePrimer", .serialized) +@Suite("ChatTimelinePrimer", .serialized, .isolatedIncomingAvatarJPEGStore) @MainActor struct ChatTimelinePrimerTests { // MARK: - Fixtures @@ -38,7 +38,8 @@ struct ChatTimelinePrimerTests { id: UUID = UUID(), name: String = "TestContact", nickname: String? = nil, - unreadCount: Int = 0 + unreadCount: Int = 0, + avatarImageData: Data? = nil ) -> ContactDTO { ContactDTO( id: id, @@ -53,12 +54,14 @@ struct ChatTimelinePrimerTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, isFavorite: false, lastMessageDate: nil, - unreadCount: unreadCount + unreadCount: unreadCount, + avatarImageData: avatarImageData ) } @@ -227,7 +230,12 @@ struct ChatTimelinePrimerTests { let channel = makeChannel(radioID: radioID) let wireName = "AlphaNode" let nickname = "Alpha" - let contact = makeContact(radioID: radioID, name: wireName, nickname: nickname) + let contact = makeContact( + radioID: radioID, + name: wireName, + nickname: nickname, + avatarImageData: Data("prime-jpeg".utf8) + ) try await dataStore.saveChannel(channel) try await dataStore.saveContact(contact) @@ -255,6 +263,8 @@ struct ChatTimelinePrimerTests { #expect(item.envelope.senderResolution.matchKind != .unresolved) #expect(item.envelope.senderResolution.displayName == wireName) #expect(item.envelope.senderResolution.unverifiedNickname == nickname) + #expect(item.envelope.incomingAvatar?.matchedContactID == contact.id) + #expect(IncomingAvatarJPEGStore.data(for: contact.id) != nil) } @Test diff --git a/MC1Tests/Utilities/MentionUtilitiesTests.swift b/MC1Tests/Utilities/MentionUtilitiesTests.swift index 92c64b933..69a9abc72 100644 --- a/MC1Tests/Utilities/MentionUtilitiesTests.swift +++ b/MC1Tests/Utilities/MentionUtilitiesTests.swift @@ -221,6 +221,7 @@ struct MentionUtilitiesTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Utilities/RepeaterResolverTests.swift b/MC1Tests/Utilities/RepeaterResolverTests.swift index 011911b0d..29fc72ea9 100644 --- a/MC1Tests/Utilities/RepeaterResolverTests.swift +++ b/MC1Tests/Utilities/RepeaterResolverTests.swift @@ -27,6 +27,7 @@ struct RepeaterResolverTests { latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -502,4 +503,33 @@ struct RepeaterResolverTests { #expect(result?.displayName == "Newer Repeater") #expect(result?.matchKind == .fallback) } + + // MARK: - Key display length + + @Test(arguments: [ + (deviceHashSize: Int?.none, expected: 2), + (deviceHashSize: 1, expected: 2), + (deviceHashSize: 2, expected: 2), + (deviceHashSize: 3, expected: 3), + (deviceHashSize: 99, expected: 3) + ]) + func `key display byte count floors at 2 and caps at 3`(deviceHashSize: Int?, expected: Int) { + #expect(NeighborNameResolver.keyDisplayByteCount(deviceHashSize: deviceHashSize) == expected) + } + + @Test + func `fallback name with byte count formats two or three prefix bytes`() { + let prefix = Data([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 2) == "DEAD") + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 3) == "DEADBE") + } + + @Test + func `fallback name with byte count clamps display max and short prefixes`() { + let prefix = Data([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 6) == "DEADBE") + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 99) == "DEADBE") + #expect(NeighborNameResolver.fallbackName(for: Data([0xAB]), byteCount: 3) == "AB") + #expect(NeighborNameResolver.fallbackName(for: Data(), byteCount: 2) == "") + } } diff --git a/MC1Tests/ViewModels/AppBackupViewModelTests.swift b/MC1Tests/ViewModels/AppBackupViewModelTests.swift index cedc99258..629f2461c 100644 --- a/MC1Tests/ViewModels/AppBackupViewModelTests.swift +++ b/MC1Tests/ViewModels/AppBackupViewModelTests.swift @@ -128,7 +128,7 @@ struct AppBackupViewModelTests { let radioID = UUID() let services = try ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: PersistenceStore.createContainer(inMemory: true), + dataStore: manager.persistenceStore, radioID: radioID ) try await services.dataStore.saveContact( @@ -145,6 +145,7 @@ struct AppBackupViewModelTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -185,12 +186,96 @@ struct AppBackupViewModelTests { if case .importing = vm.importState { Issue.record("import ran while connected") } } + @Test + func `performImport writes through the process persistence store`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + let radioID = UUID() + let publicKey = Data(repeating: 0x51, count: 32) + try await manager.persistenceStore.saveContact( + makeContact( + radioID: radioID, + publicKey: publicKey, + nickname: nil, + isBlocked: false, + unreadCount: 0 + ) + ) + + let vm = AppBackupViewModel(connectionManager: manager) + vm.importState = .preview( + AppBackupEnvelope( + appVersion: "test", + appBuild: "1", + contacts: [ + makeContact( + radioID: radioID, + publicKey: publicKey, + nickname: "Field Ops", + isBlocked: true, + unreadCount: 7 + ) + ] + ) + ) + vm.performImport() + + try await waitUntil("Import never completed") { + switch vm.importState { + case .success, .failed, .cancelled: true + default: false + } + } + + guard case .success = vm.importState else { + Issue.record("import did not succeed: \(String(describing: vm.importState))") + return + } + + let after = try #require( + await manager.persistenceStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(after.nickname == "Field Ops") + #expect(after.isBlocked == true) + #expect(after.unreadCount == 7) + } + private func makeViewModel() throws -> AppBackupViewModel { let container = try PersistenceStore.createContainer(inMemory: true) let manager = ConnectionManager(modelContainer: container) return AppBackupViewModel(connectionManager: manager) } + private func makeContact( + radioID: UUID, + publicKey: Data, + nickname: String?, + isBlocked: Bool, + unreadCount: Int + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: publicKey, + name: "Alice", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nickname, + isBlocked: isBlocked, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: unreadCount + ) + } + private func makeReadableBackupURL() throws -> URL { let envelope = AppBackupEnvelope( appVersion: "test", diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index d79786beb..f27aed591 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -24,6 +24,7 @@ private func createTestContact( latitude: 0, longitude: 0, lastModified: UInt32(Date().timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -136,7 +137,8 @@ private func envInputsChangingAppearance() -> EnvInputs { isOffline: base.isOffline, currentUserName: base.currentUserName, themeID: base.themeID, - contentSizeCategory: base.contentSizeCategory + contentSizeCategory: base.contentSizeCategory, + preferredLanguageCode: base.preferredLanguageCode ) } @@ -196,7 +198,7 @@ struct ChatViewModelPaginationTests { try await dataStore.saveMessage(message) } - #expect(await viewModel.primeInitialMessages(for: contact), "Initial open must succeed") + #expect(await viewModel.primeInitialMessages(for: contact, populateMode: .replace), "Initial open must succeed") #expect(viewModel.messages.count == ChatCoordinator.pageSize) #expect(viewModel.isLoadingOlder == false) @@ -216,10 +218,8 @@ struct ChatViewModelPaginationTests { struct ChatViewModelChannelPaginationTests { @Test func `Opening a channel with more unread than one page loads the divider target`() async throws { - // When unread exceeds pageSize, the first-unread message (where the divider - // belongs) is older than a standard page. The initial load must be sized to - // cover all unread so the divider has a materialized message to scroll to, - // instead of clamping the divider onto the oldest loaded row. + // Unread past pageSize but under maxInitialPageSize: grow the first page so + // the divider lands on the true first-unread row, not the oldest loaded. let container = try PersistenceStore.createContainer(inMemory: true) let dataStore = PersistenceStore(modelContainer: container) let radioID = UUID() @@ -266,10 +266,10 @@ struct ChatViewModelChannelPaginationTests { // the load task runs; an unstaged open reads as already presented at the // bottom and bakes no divider. viewModel.timeline.stageOpen(.channel(channel)) - await viewModel.loadChannelMessages(for: channel) + await viewModel.loadChannelMessages(for: channel, populateMode: .replace) #expect(viewModel.messages.count == ChatCoordinator.initialPageSize(unreadCount: unread), - "Initial load must fetch all unread plus read context, not just one page") + "Under the cap, initial load must fetch unread plus read context, not one page") #expect(viewModel.bake.newMessagesDividerMessageID == expectedDividerID, "Divider must land on the true first-unread message") #expect(viewModel.messages.contains { $0.id == expectedDividerID }, @@ -278,6 +278,61 @@ struct ChatViewModelChannelPaginationTests { "Newest message must still be loaded") } + @Test + func `Opening a channel with unread past the initial-page cap loads the newest window only`() async throws { + // Past the cap only the newest window loads. The divider clamps to the oldest + // loaded row and stays there; remaining unread sits above it after loadOlder. + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channelIndex: UInt8 = 0 + let total = ChatCoordinator.maxInitialPageSize + 100 + let unread = ChatCoordinator.maxInitialPageSize + 50 + #expect(unread > ChatCoordinator.maxInitialPageSize) + + let channel = ChannelDTO( + id: UUID(), + radioID: radioID, + index: channelIndex, + name: "Public Channel", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: unread, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + try await dataStore.saveChannel(channel) + + var idsOldestFirst: [UUID] = [] + for index in 0.. 300 + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == false) // 300 is not > 300 } @Test @@ -172,7 +173,7 @@ struct ChatViewModelTests { 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(ChatMessageBakeState.computeDisplayFlags(for: later, previous: earlier).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: later, previous: earlier, next: nil).showTimestamp == true) } @Test @@ -289,12 +290,12 @@ struct ChatViewModelTests { createTestMessage(timestamp: baseTime + 920) // 5: 20 sec - no show ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showTimestamp == true) // 360s gap - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[3], previous: messages[2]).showTimestamp == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[4], previous: messages[3]).showTimestamp == true) // 420s gap - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[5], previous: messages[4]).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showTimestamp == true) // 360s gap + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[3], previous: messages[2], next: nil).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[4], previous: messages[3], next: nil).showTimestamp == true) // 420s gap + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[5], previous: messages[4], next: nil).showTimestamp == false) } @Test @@ -337,7 +338,8 @@ struct ChatViewModelTests { isOffline: EnvInputs.default.isOffline, currentUserName: EnvInputs.default.currentUserName, themeID: EnvInputs.default.themeID, - contentSizeCategory: EnvInputs.default.contentSizeCategory + contentSizeCategory: EnvInputs.default.contentSizeCategory, + preferredLanguageCode: EnvInputs.default.preferredLanguageCode ) viewModel.applyEnvInputs(darkEnv) await coordinator.buildItemsTask?.value @@ -375,7 +377,8 @@ struct ChatViewModelTests { isOffline: EnvInputs.default.isOffline, currentUserName: EnvInputs.default.currentUserName, themeID: Theme.ember.id, - contentSizeCategory: EnvInputs.default.contentSizeCategory + contentSizeCategory: EnvInputs.default.contentSizeCategory, + preferredLanguageCode: EnvInputs.default.preferredLanguageCode ) viewModel.applyEnvInputs(emberEnv) await coordinator.buildItemsTask?.value @@ -391,7 +394,7 @@ struct ChatViewModelTests { let first = createTestMessage(timestamp: baseTime, text: "Hello") let second = createTestMessage(timestamp: baseTime, text: "World") - let flags = ChatMessageBakeState.computeDisplayFlags(for: second, previous: first) + let flags = ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) #expect(flags.showTimestamp == false) #expect(flags.showDirectionGap == false) } @@ -402,7 +405,7 @@ struct ChatViewModelTests { createTestMessage(timestamp: 1000) ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) } @Test @@ -411,7 +414,7 @@ struct ChatViewModelTests { // sessions, so distinct anchors). Grouping follows send time, so no header appears. let msg1 = createTestMessage(timestamp: 1000, sortDate: Date(timeIntervalSince1970: 1_000_000)) let msg2 = createTestMessage(timestamp: 1001, sortDate: Date(timeIntervalSince1970: 1_000_600)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1, next: nil).showTimestamp == false) } @Test @@ -420,7 +423,7 @@ struct ChatViewModelTests { // follow send time, not drain time, so the rows stay grouped with no header. let msg1 = createTestMessage(timestamp: 1000, createdAt: Date(timeIntervalSince1970: 2_000_000)) let msg2 = createTestMessage(timestamp: 1001, createdAt: Date(timeIntervalSince1970: 2_000_600)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1, next: nil).showTimestamp == false) } @Test @@ -431,8 +434,8 @@ struct ChatViewModelTests { createTestMessage(timestamp: baseTime + 86400) // 24 hours later ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == true) } // MARK: - Conversation Filtering Tests @@ -523,7 +526,7 @@ struct ChatViewModelTests { let coordinator = ChatCoordinator.makeForTesting() viewModel.bindCoordinatorForTesting(coordinator) - await viewModel.loadMessages(for: createTestContact()) + await viewModel.loadMessages(for: createTestContact(), populateMode: .replace) #expect(viewModel.renderState.phase == .loaded) } @@ -539,7 +542,7 @@ struct ChatViewModelTests { index: 1, name: "Test" )) - await viewModel.loadChannelMessages(for: channel) + await viewModel.loadChannelMessages(for: channel, populateMode: .replace) #expect(viewModel.renderState.phase == .loaded) } @@ -609,7 +612,7 @@ struct ChatViewModelTests { let imageURL = try #require(URL(string: "https://example.com/photo.png")) viewModel.bake.cachedURLs[message.id] = imageURL - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) #expect(inputs.isInlineImageURL == true) } @@ -625,7 +628,7 @@ struct ChatViewModelTests { viewModel.bake.cachedURLs[message.id] = pageURL viewModel.bake.imageURLsServingPages.insert(pageURL.absoluteString) - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) #expect(inputs.isInlineImageURL == false) } @@ -728,7 +731,7 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1000, senderName: "Alice") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) } @Test @@ -739,9 +742,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: "Alice") // 2 min later ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == false) } @Test @@ -751,8 +754,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: "Bob") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -762,8 +765,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1301, senderName: "Alice") // 5 min 1 sec later ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -773,8 +776,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1300, senderName: "Alice") // Exactly 5 min ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) } @Test @@ -785,9 +788,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: "Alice") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == true) } @Test @@ -798,9 +801,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: nil, isOutgoing: true) ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == false) } @Test @@ -811,9 +814,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: "Alice") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == true) } @Test @@ -823,8 +826,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: nil) // malformed message ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -834,8 +837,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: "") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -852,7 +855,7 @@ struct DisplayFlagsTests { ) let dto = MessageDTO(from: message) - #expect(ChatMessageBakeState.computeDisplayFlags(for: dto, previous: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: dto, previous: nil, next: nil).showSenderName == true) } @Test @@ -862,9 +865,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: "Alice", isOutgoing: true), createChannelMessage(timestamp: 1120, senderName: "Alice") ] - let flags0 = ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil) - let flags1 = ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]) - let flags2 = ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]) + let flags0 = ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil) + let flags1 = ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil) + let flags2 = ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil) #expect(flags0.showDirectionGap == false) #expect(flags1.showDirectionGap == true) #expect(flags2.showDirectionGap == true) @@ -875,21 +878,21 @@ struct DisplayFlagsTests { @Test func `First message always shows day divider`() { let message = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: message, previous: nil).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: message, previous: nil, next: nil).showDayDivider == true) } @Test func `Same calendar day hides day divider`() { let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10, 0)) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10, 1)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == false) } @Test func `Calendar day change shows day divider`() { let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10)) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 2, 10)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == true) } @Test @@ -899,7 +902,7 @@ struct DisplayFlagsTests { let receiveDay = makeDate(2024, 6, 2, 14) let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10), createdAt: receiveDay) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 2, 10), createdAt: receiveDay) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == true) } @Test @@ -908,7 +911,7 @@ struct DisplayFlagsTests { // messages straddle midnight, so the day divider must still show. let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 23, 58)) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 2, 0, 1)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == true) } } @@ -933,7 +936,8 @@ struct ChatViewModelImageGatingTests { isOffline: false, currentUserName: "Me", themeID: EnvInputs.defaultThemeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } @@ -1102,7 +1106,8 @@ struct ChatViewModelOrphanRecoveryTests { isOffline: false, currentUserName: "Me", themeID: EnvInputs.defaultThemeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } diff --git a/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift new file mode 100644 index 000000000..035e95c7b --- /dev/null +++ b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift @@ -0,0 +1,371 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("ChatViewModel translation") +@MainActor +struct ChatViewModelTranslationTests { + private let german = "Guten Morgen, wie geht es dir heute?" + private let showingEnglish = MessageTranslationChrome.Phase.showing( + translatedText: "Hello", + targetLanguageCode: "en" + ) + + @Test + func `toggle offer goes inProgress then showing and leaves stored text unchanged`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + let original = message.text + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest?.messageID == message.id) + + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + #expect(viewModel.messagesByID[message.id]?.text == original) + #expect(translator.translateCount == 1) + let reply = MentionUtilities.buildReplyText(mentionName: "Alice", messageText: original) + #expect(reply.contains("Guten Morg")) + #expect(!reply.contains("Hello")) + } + + @Test + func `toggle showing returns to offer and second translate hits cache`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.displayedText(for: message) == german) + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(translator.translateCount == 1) + #expect(viewModel.translationSessionRequest == nil) + } + + @Test + func `translator throw presents system overlay and returns to offer`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(TranslationTestError.failed) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(result == .presentSystemOverlay(text: german)) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.messagesByID[message.id]?.text == german) + } + + @Test + func `needs download leaves the in-flight request for the system sheet`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(MessageTranslationNeedsDownloadError()) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(result == .needsDownload) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translationSessionRequest?.messageID == message.id) + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + } + + @Test + func `cancellation leaves offer without errorMessage`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(CancellationError()) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(result == .finished) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translation(for: message.id)?.phase == .offer) + } + + @Test + func `download UI cancel leaves offer without errorMessage`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(CocoaError(.userCancelled)) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(result == .finished) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.messagesByID[message.id]?.text == german) + } + + @Test + func `second tap while inProgress resets the first and applies the second`() async throws { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + + let first = germanMessage(timestamp: 1000) + let second = germanMessage(timestamp: 1001) + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + await coordinator.buildItemsTask?.value + + let gated = GatedMessageTranslator() + viewModel.performTranslationAction(for: first.id) + #expect(viewModel.bake.translationPhases[first.id] == .inProgress) + let firstRequest = try #require(viewModel.translationSessionRequest) + + async let pending = viewModel.performPendingTranslation(using: gated, for: firstRequest) + await gated.waitUntilEntered() + + viewModel.performTranslationAction(for: second.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: first.id)?.phase == .offer) + #expect(viewModel.translation(for: second.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest?.messageID == second.id) + + gated.resume(returning: "Stale hello") + #expect(await pending == .finished) + await coordinator.buildItemsTask?.value + + #expect(viewModel.bake.translationCache[first.id] == nil) + #expect(viewModel.bake.translationCache[second.id] == nil) + #expect(viewModel.translation(for: first.id)?.phase == .offer) + #expect(viewModel.translation(for: second.id)?.phase == .inProgress) + + let counting = CountingMessageTranslator() + try await performCurrent(viewModel, using: counting) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: second.id)?.phase == showingEnglish) + #expect(viewModel.bake.translationCache[first.id] == nil) + #expect(counting.translateCount == 1) + } + + @Test + func `cancelPendingTranslation while inProgress returns to offer`() async throws { + let (viewModel, coordinator, message, _) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + + viewModel.cancelPendingTranslation() + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + } + + @Test + func `cancelPendingTranslation while showing leaves showing and cache`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + + viewModel.cancelPendingTranslation() + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.bake.translationCache[message.id]?["en"] == "Hello") + #expect(translator.translateCount == 1) + } + + @Test + func `preferred language change does not reuse a translation for a different target`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + #expect(viewModel.displayedText(for: message) == "Hello") + + viewModel.applyEnvInputs(envInputs(preferredLanguageCode: "fr")) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.displayedText(for: message) == german) + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest?.targetLanguageCode == "fr") + #expect(viewModel.bake.translationCache[message.id]?["en"] == "Hello") + + viewModel.applyEnvInputs(envInputs(preferredLanguageCode: "en")) + await coordinator.buildItemsTask?.value + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(translator.translateCount == 1) + } + + @Test + func `preferred language change while inProgress returns to offer and drops the request`() async throws { + let (viewModel, coordinator, message, _) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest != nil) + + viewModel.applyEnvInputs(envInputs(preferredLanguageCode: "fr")) + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + } + + @Test + func `outgoing foreign-language message has no translation chrome`() async throws { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + let message = germanMessage(timestamp: 1000, direction: .outgoing) + viewModel.appendMessageIfNew(message) + await coordinator.buildItemsTask?.value + + let item = try #require(viewModel.items.first { $0.id == message.id }) + #expect(item.translation == nil) + #expect(viewModel.bake.detectedLanguages[message.id] == nil) + } + + @Test + func `stale needs-download does not consume the newer request`() async throws { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + let first = germanMessage(timestamp: 1000) + let second = germanMessage(timestamp: 1001) + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + await coordinator.buildItemsTask?.value + + let gated = GatedMessageTranslator() + viewModel.performTranslationAction(for: first.id) + let requestA = try #require(viewModel.translationSessionRequest) + async let pending = viewModel.performPendingTranslation(using: gated, for: requestA) + await gated.waitUntilEntered() + + viewModel.performTranslationAction(for: second.id) + await coordinator.buildItemsTask?.value + gated.resume(throwing: MessageTranslationNeedsDownloadError()) + let result = await pending + await coordinator.buildItemsTask?.value + + #expect(result == .finished) + #expect(viewModel.translationSessionRequest?.messageID == second.id) + #expect(viewModel.translation(for: second.id)?.phase == .inProgress) + } + + @discardableResult + private func performCurrent( + _ viewModel: ChatViewModel, + using translator: any MessageTranslating + ) async throws -> TranslationPerformResult { + let request = try #require(viewModel.translationSessionRequest) + return await viewModel.performPendingTranslation(using: translator, for: request) + } + + private func seededGermanChat() async throws -> ( + ChatViewModel, ChatCoordinator, MessageDTO, CountingMessageTranslator + ) { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + let message = germanMessage(timestamp: 1000) + viewModel.appendMessageIfNew(message) + await coordinator.buildItemsTask?.value + let chrome = try #require(viewModel.translation(for: message.id)) + #expect(chrome.phase == .offer) + return (viewModel, coordinator, message, CountingMessageTranslator()) + } + + private func germanMessage( + timestamp: UInt32, + direction: MessageDirection = .incoming + ) -> MessageDTO { + makeMessage(timestamp: timestamp, text: german, direction: direction) + } + + private func envInputs(preferredLanguageCode: String) -> EnvInputs { + let base = EnvInputs.default + return EnvInputs( + autoPlayGIFs: base.autoPlayGIFs, + showIncomingPath: base.showIncomingPath, + showIncomingHopCount: base.showIncomingHopCount, + showIncomingRegion: base.showIncomingRegion, + showIncomingSendTime: base.showIncomingSendTime, + previewsEnabled: base.previewsEnabled, + isHighContrast: base.isHighContrast, + isDark: base.isDark, + showMapPreviews: base.showMapPreviews, + isOffline: base.isOffline, + currentUserName: base.currentUserName, + themeID: base.themeID, + contentSizeCategory: base.contentSizeCategory, + preferredLanguageCode: preferredLanguageCode + ) + } + + private func makeMessage( + timestamp: UInt32, + text: String, + direction: MessageDirection = .incoming + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: direction, + status: direction == .outgoing ? .sent : .delivered, + 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 enum TranslationTestError: Error { + case failed +} + +@MainActor +private final class CountingMessageTranslator: MessageTranslating { + var translateCount = 0 + var result: Result = .success("Hello") + + func translate(_: String) async throws -> String { + translateCount += 1 + return try result.get() + } +} diff --git a/MC1Tests/ViewModels/ContactsViewModelTests.swift b/MC1Tests/ViewModels/ContactsViewModelTests.swift index c0e23555b..ab403dd5b 100644 --- a/MC1Tests/ViewModels/ContactsViewModelTests.swift +++ b/MC1Tests/ViewModels/ContactsViewModelTests.swift @@ -31,6 +31,7 @@ private func createContact( latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/ViewModels/GatedMessageTranslator.swift b/MC1Tests/ViewModels/GatedMessageTranslator.swift new file mode 100644 index 000000000..cfb385e42 --- /dev/null +++ b/MC1Tests/ViewModels/GatedMessageTranslator.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import MC1 + +/// Test translator that parks inside `translate` until `resume` is called. +@MainActor +final class GatedMessageTranslator: MessageTranslating { + private var waitForEnter: CheckedContinuation? + private var gate: CheckedContinuation? + private var didEnter = false + private(set) var translateCount = 0 + + func translate(_: String) async throws -> String { + translateCount += 1 + return try await withCheckedThrowingContinuation { continuation in + gate = continuation + didEnter = true + if let waitForEnter { + waitForEnter.resume() + self.waitForEnter = nil + } + } + } + + func waitUntilEntered() async { + if didEnter { return } + await withCheckedContinuation { continuation in + waitForEnter = continuation + } + } + + func resume(returning value: String) { + gate?.resume(returning: value) + gate = nil + } + + func resume(throwing error: Error) { + gate?.resume(throwing: error) + gate = nil + } +} diff --git a/MC1Tests/ViewModels/IncomingAvatarClusterTests.swift b/MC1Tests/ViewModels/IncomingAvatarClusterTests.swift new file mode 100644 index 000000000..c5be7820e --- /dev/null +++ b/MC1Tests/ViewModels/IncomingAvatarClusterTests.swift @@ -0,0 +1,401 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftData +import Testing + +@Suite("Incoming avatar cluster", .isolatedIncomingAvatarJPEGStore) +@MainActor +struct IncomingAvatarClusterTests { + init() { + MapSnapshotStore.shared.clear() + } + + @Test + func `two incoming channel same sender 60s — first hides avatar, second shows`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `sender change — both incoming channel rows show avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Bob") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `incoming then outgoing channel — incoming shows avatar`() { + let incoming = createChannelMessage(timestamp: 1000, senderName: "Alice") + let outgoing = createChannelMessage(timestamp: 1060, senderName: nil, isOutgoing: true) + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: incoming, previous: nil, next: outgoing) + .isClusterEnd == true + ) + } + + @Test + func `300s gap — first incoming channel hides avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1300, senderName: "Alice") + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `two nil-name incoming channel 60s — both show avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: nil) + let second = createChannelMessage(timestamp: 1060, senderName: nil) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `301s gap — first incoming channel shows avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1301, senderName: "Alice") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + } + + @Test + func `day change within 300s same sender — cluster continues`() { + let first = createChannelMessage( + timestamp: makeTimestamp(2024, 5, 1, 23, 58), + senderName: "Alice" + ) + let second = createChannelMessage( + timestamp: makeTimestamp(2024, 5, 2, 0, 1), + senderName: "Alice" + ) + + let firstFlags = ChatMessageBakeState.computeDisplayFlags( + for: first, previous: nil, next: second + ) + let secondFlags = ChatMessageBakeState.computeDisplayFlags( + for: second, previous: first, next: nil + ) + #expect(firstFlags.isClusterEnd == false) + #expect(secondFlags.isClusterEnd == true) + #expect(secondFlags.showDayDivider == true) + } + + @Test + func `two incoming DMs 60s apart both hide avatar`() { + let first = createIncomingDM(timestamp: 1000) + let second = createIncomingDM(timestamp: 1060) + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == false + ) + } + + @Test + func `outgoing channel hides avatar`() { + let message = createChannelMessage(timestamp: 1000, senderName: nil, isOutgoing: true) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: message, previous: nil, next: nil) + .isClusterEnd == false + ) + } + + @Test + func `two empty-name incoming channel 60s — first hides avatar, second shows`() { + let first = createChannelMessage(timestamp: 1000, senderName: "") + let second = createChannelMessage(timestamp: 1060, senderName: "") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `Alice then empty name — both incoming channel rows show avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `channel follow-up admit hands avatar to the new cluster-end`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + + viewModel.appendMessageIfNew(second) + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `incoming then outgoing channel admit keeps incoming avatar`() { + let viewModel = boundViewModel() + let incoming = createChannelMessage(timestamp: 1000, senderName: "Alice") + let outgoing = createChannelMessage(timestamp: 1060, senderName: nil, isOutgoing: true) + + viewModel.appendMessageIfNew(incoming) + viewModel.appendMessageIfNew(outgoing) + + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + #expect(viewModel.items[1].envelope.incomingAvatar == nil) + } + + @Test + func `DM admits do not rewrite the previous item`() throws { + let viewModel = boundViewModel() + let first = createIncomingDM(timestamp: 1000) + let second = createIncomingDM(timestamp: 1060) + + viewModel.appendMessageIfNew(first) + let afterFirst = try #require(viewModel.items.first) + + viewModel.appendMessageIfNew(second) + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0] == afterFirst) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar == nil) + } + + @Test + func `delete last incoming channel promotes the previous avatar`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + + viewModel.timeline.removeMessage(second.id) + + #expect(viewModel.items.count == 1) + #expect(viewModel.items[0].id == first.id) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + } + + @Test + func `delete middle of 250s Alice cluster splits into two cluster-ends`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let middle = createChannelMessage(timestamp: 1250, senderName: "Alice") + let last = createChannelMessage(timestamp: 1500, senderName: "Alice") + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(middle) + viewModel.appendMessageIfNew(last) + + viewModel.timeline.removeMessage(middle.id) + + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + #expect(viewModel.items[1].grouping.showSenderName == true) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `delete middle of a tight Alice burst keeps one cluster-end`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let middle = createChannelMessage(timestamp: 1060, senderName: "Alice") + let last = createChannelMessage(timestamp: 1120, senderName: "Alice") + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(middle) + viewModel.appendMessageIfNew(last) + + viewModel.timeline.removeMessage(middle.id) + + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `delete incoming DM leaves remaining item bit-identical`() { + let viewModel = boundViewModel() + let first = createIncomingDM(timestamp: 1000) + let second = createIncomingDM(timestamp: 1060) + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + let remaining = viewModel.items[0] + + viewModel.timeline.removeMessage(second.id) + + #expect(viewModel.items.count == 1) + #expect(viewModel.items[0] == remaining) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + } + + @Test + func `contact-table patch rewrites only rows that already have an identity`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channel = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 0, + name: "Ops", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + let alice = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. ChatViewModel { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + return viewModel +} + +private func createChannelMessage( + timestamp: UInt32, + senderName: String? = nil, + isOutgoing: Bool = false +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, + channelIndex: 0, + text: "Test message", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: isOutgoing ? .outgoing : .incoming, + status: isOutgoing ? .sent : .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 createIncomingDM(timestamp: UInt32) -> MessageDTO { + let message = Message( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + text: "Test", + timestamp: timestamp, + directionRawValue: MessageDirection.incoming.rawValue, + statusRawValue: MessageStatus.delivered.rawValue + ) + return MessageDTO(from: message) +} + +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) + )! +} + +private func makeTimestamp(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int = 0) -> UInt32 { + UInt32(makeDate(year, month, day, hour, minute).timeIntervalSince1970) +} diff --git a/MC1Tests/ViewModels/IncomingAvatarIdentityTests.swift b/MC1Tests/ViewModels/IncomingAvatarIdentityTests.swift new file mode 100644 index 000000000..2826ee081 --- /dev/null +++ b/MC1Tests/ViewModels/IncomingAvatarIdentityTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("IncomingAvatarIdentity") +struct IncomingAvatarIdentityTests { + @Test + func `revision of nil or empty is nil`() { + #expect(IncomingAvatarIdentity.revision(of: nil) == nil) + #expect(IncomingAvatarIdentity.revision(of: Data()) == nil) + } + + @Test + func `resolve trims senderNodeName before table lookup`() { + let photo = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: " Alice ", + displayName: " Alice ", + table: ["alice": photo] + ) + #expect(resolved == photo) + } + + @Test + func `resolve does not use displayName as a table key`() { + let photo = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: nil, + displayName: "Alice", + table: ["alice": photo] + ) + #expect(resolved == IncomingAvatarIdentity.initials(name: "Alice")) + } + + @Test + func `resolve does not steal another contact photo`() { + let alice = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: "Bob", + displayName: "Bob", + table: ["alice": alice] + ) + #expect(resolved == IncomingAvatarIdentity.initials(name: "Bob")) + } + + @Test + func `resolve empty senderNodeName is initials`() { + let photo = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: "", + displayName: "Alice", + table: ["alice": photo] + ) + #expect(resolved == IncomingAvatarIdentity.initials(name: "Alice")) + } +} diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 1464513ea..e7e25e466 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -104,6 +104,23 @@ actor MockPersistenceStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } @@ -140,6 +157,11 @@ actor MockPersistenceStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} @@ -417,7 +439,7 @@ actor MockPersistenceStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -425,10 +447,17 @@ actor MockPersistenceStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } // MARK: - Channel Message Deletion (stubs) @@ -518,6 +547,7 @@ private func createTestContact( latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/MessagePathViewModelTests.swift b/MC1Tests/ViewModels/MessagePathViewModelTests.swift index 5a656c290..1096c7529 100644 --- a/MC1Tests/ViewModels/MessagePathViewModelTests.swift +++ b/MC1Tests/ViewModels/MessagePathViewModelTests.swift @@ -10,7 +10,10 @@ struct MessagePathViewModelTests { prefix: [UInt8], name: String, type: ContactType = .chat, - lastAdvertTimestamp: UInt32 = 0 + lastAdvertTimestamp: UInt32 = 0, + latitude: Double = 0, + longitude: Double = 0, + isBlocked: Bool = false ) -> ContactDTO { ContactDTO( id: UUID(), @@ -22,11 +25,12 @@ struct MessagePathViewModelTests { outPathLength: 0, outPath: Data(), lastAdvertTimestamp: lastAdvertTimestamp, - latitude: 0, - longitude: 0, + latitude: latitude, + longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, - isBlocked: false, + isBlocked: isBlocked, isMuted: false, isFavorite: false, lastMessageDate: nil, @@ -164,6 +168,172 @@ struct MessagePathViewModelTests { #expect(viewModel.senderNodeID(for: message) == "0A") } + // MARK: - locatedSender + + private static let locatedLatitude = 37.7749 + private static let locatedLongitude = -122.4194 + + @Test + func `locatedSender matches DM by key prefix`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alpha", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender(for: createMessage(senderKeyPrefix: contact.publicKeyPrefix)) + + #expect(result?.id == contact.id) + } + + @Test + func `locatedSender returns nil for DM prefix match without location`() { + let viewModel = MessagePathViewModel() + let contact = createContact(prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], name: "Alpha") + viewModel.contacts = [contact] + + let result = viewModel.locatedSender(for: createMessage(senderKeyPrefix: contact.publicKeyPrefix)) + + #expect(result == nil) + } + + @Test + func `locatedSender matches unique channel sender name with location`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result?.id == contact.id) + } + + @Test + func `locatedSender returns nil for unique channel name without location`() { + let viewModel = MessagePathViewModel() + let contact = createContact(prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], name: "RemoteNode") + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result == nil) + } + + @Test + func `locatedSender returns nil when two contacts share the channel sender name`() { + let viewModel = MessagePathViewModel() + let located = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alice", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + let unlocated = createContact(prefix: [0xBB, 0x02, 0x00, 0x00, 0x00, 0x00], name: "Alice") + viewModel.contacts = [located, unlocated] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "Alice", channelIndex: 0) + ) + + #expect(result == nil) + } + + @Test + func `locatedSender returns nil when no contact matches the channel sender name`() { + let viewModel = MessagePathViewModel() + viewModel.contacts = [ + createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alpha", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + ] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result == nil) + } + + @Test + func `locatedSender prefers key prefix over channel name when prefix matches`() { + let viewModel = MessagePathViewModel() + let prefixed = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alpha", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + let named = createContact( + prefix: [0xBB, 0x02, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: 37.8, + longitude: -122.5 + ) + viewModel.contacts = [prefixed, named] + + let result = viewModel.locatedSender( + for: createMessage( + senderKeyPrefix: prefixed.publicKeyPrefix, + senderNodeName: "RemoteNode", + channelIndex: 0 + ) + ) + + #expect(result?.id == prefixed.id) + } + + @Test + func `locatedSender matches channel sender name case-insensitively`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "remotenode", channelIndex: 0) + ) + + #expect(result?.id == contact.id) + } + + @Test + func `locatedSender includes a unique blocked contact`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude, + isBlocked: true + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result?.id == contact.id) + } + // MARK: - repeaterName @Test diff --git a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift index 686db4418..a2fdaed89 100644 --- a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift +++ b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift @@ -268,3 +268,144 @@ struct NodeSettingsRadioApplyTests { #expect(recorder.commands == ["get radio"]) } } + +@Suite("NodeSettingsViewModel clock sync") +@MainActor +struct NodeSettingsClockSyncTests { + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + var reply = "OK - clock set: 15:35 - 14/8/2026 UTC" + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + return reply + } + } + + private func makeConfiguredViewModel(recorder: CommandRecorder) -> NodeSettingsViewModel { + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Node", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ) + let viewModel = NodeSettingsViewModel() + viewModel.configure(session: session, sendCommand: recorder.send, sendRawCommand: recorder.send) + return viewModel + } + + @Test + func `syncTime sends the host epoch as time not bare clock sync`() async throws { + let recorder = CommandRecorder() + let viewModel = makeConfiguredViewModel(recorder: recorder) + let before = UInt32(Date().timeIntervalSince1970) + + await viewModel.syncTime() + + let after = UInt32(Date().timeIntervalSince1970) + #expect(recorder.commands.count == 1) + let command = try #require(recorder.commands.first) + #expect(command.hasPrefix("time ")) + let epochText = String(command.dropFirst(5)) + let epoch = try #require(UInt32(epochText)) + #expect(epoch >= before && epoch <= after) + } + + @Test + func `fetchDeviceInfo records drift from clock against a fixed now`() async throws { + let recorder = CommandRecorder() + recorder.reply = "06:40 - 18/4/2025 UTC" + let viewModel = makeConfiguredViewModel(recorder: recorder) + let node = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: recorder.reply)) + viewModel.now = { node.addingTimeInterval(3600) } + + await viewModel.fetchDeviceInfo() + + #expect(viewModel.clockDrift == -3600) + } + + @Test + func `syncTime on OK with clock text updates device time and drift`() async throws { + let recorder = CommandRecorder() + recorder.reply = "OK - clock set: 15:35 - 14/8/2026 UTC" + let viewModel = makeConfiguredViewModel(recorder: recorder) + let node = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: recorder.reply)) + viewModel.now = { node } + + await viewModel.syncTime() + + #expect(recorder.commands.count == 1) + #expect(viewModel.clockDrift == 0) + #expect(viewModel.deviceTime != nil) + } + + @Test + func `syncTime on bare OK hides the warning without a second command`() async { + let recorder = CommandRecorder() + recorder.reply = "OK - clock set" + let viewModel = makeConfiguredViewModel(recorder: recorder) + viewModel.clockDrift = -10000 + + await viewModel.syncTime() + + #expect(recorder.commands.count == 1) + #expect(viewModel.clockDrift == nil) + } +} + +@Suite("NodeSettingsViewModel contact info") +@MainActor +struct NodeSettingsContactInfoTests { + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + var reply = "OK" + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + return reply + } + } + + private func makeConfiguredViewModel(recorder: CommandRecorder) -> NodeSettingsViewModel { + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Node", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ) + let viewModel = NodeSettingsViewModel() + viewModel.configure(session: session, sendCommand: recorder.send, sendRawCommand: recorder.send) + return viewModel + } + + @Test + func `apply converts display newlines to the pipe wire form`() async { + let recorder = CommandRecorder() + let viewModel = makeConfiguredViewModel(recorder: recorder) + viewModel.setNodeInfo(firmwareVersion: "v1.17.1", name: "Repeater", ownerInfo: "KD7ABC") + viewModel.ownerInfo = "KD7ABC\nch 31" + + await viewModel.applyContactInfoSettings() + + #expect(recorder.commands == ["set owner.info KD7ABC|ch 31"]) + } + + @Test + func `apply failure sets errorMessage`() async { + let recorder = CommandRecorder() + recorder.reply = "ERR: not allowed" + let viewModel = makeConfiguredViewModel(recorder: recorder) + viewModel.setNodeInfo(firmwareVersion: "v1.17.1", name: "Repeater", ownerInfo: "old") + viewModel.ownerInfo = "new" + + await viewModel.applyContactInfoSettings() + + #expect(viewModel.errorMessage != nil) + #expect(viewModel.contactInfoApplySuccess == false) + #expect(viewModel.originalOwnerInfo == "old") + } +} diff --git a/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift b/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift index a2d502130..5eae77d0e 100644 --- a/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift +++ b/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift @@ -592,6 +592,7 @@ extension ContactDTO { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift index 20b92c1ea..bcc136d99 100644 --- a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift +++ b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift @@ -33,7 +33,7 @@ struct RemoteNodeStatusHandlerSurvivalTests { private func makeServices() throws -> ServiceContainer { try ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: PersistenceStore.createContainer(inMemory: true), + dataStore: PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)), radioID: UUID() ) } @@ -64,6 +64,7 @@ struct RemoteNodeStatusHandlerSurvivalTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -85,7 +86,8 @@ struct RemoteNodeStatusHandlerSurvivalTests { viewModel.configure( repeaterAdminService: { service }, contactService: { nil }, - nodeSnapshotService: { nil } + nodeSnapshotService: { nil }, + deviceHashSize: { nil } ) await viewModel.registerHandlers() @@ -152,7 +154,8 @@ struct RemoteNodeStatusHandlerSurvivalTests { viewModel.configure( repeaterAdminService: { service }, contactService: { services.contactService }, - nodeSnapshotService: { services.nodeSnapshotService } + nodeSnapshotService: { services.nodeSnapshotService }, + deviceHashSize: { nil } ) await viewModel.registerHandlers() await service.setStatusHandler { _ in statusFlag.set() } @@ -205,7 +208,8 @@ struct RemoteNodeStatusHandlerSurvivalTests { viewModel.configure( repeaterAdminService: { service }, contactService: { services.contactService }, - nodeSnapshotService: { services.nodeSnapshotService } + nodeSnapshotService: { services.nodeSnapshotService }, + deviceHashSize: { nil } ) await viewModel.cleanup() diff --git a/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift b/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift new file mode 100644 index 000000000..9599dc014 --- /dev/null +++ b/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift @@ -0,0 +1,397 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// A successful `region put` stores `floodAllowed: true`. Firmware sets flags to 0 on put. +@Suite("RepeaterSettingsViewModel add region") +@MainActor +struct RepeaterSettingsRegionTests { + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + var reply: String = "OK - (flood allowed)" + var repliesByCommand: [String: String] = [:] + var errorsByCommand: [String: Error] = [:] + + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + if let error = errorsByCommand[command] { throw error } + return repliesByCommand[command] ?? reply + } + } + + private func makeViewModel(recorder: CommandRecorder) -> RepeaterSettingsViewModel { + let viewModel = RepeaterSettingsViewModel() + viewModel.helper.configure( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ), + sendCommand: recorder.send, + sendRawCommand: recorder.send + ) + return viewModel + } + + @Test + func `firmware flood-allow put reply adds the region as flood allowed`() async { + let recorder = CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "Europe") + + #expect(recorder.commands == ["region put Europe"]) + #expect(viewModel.helper.errorMessage == nil) + #expect(viewModel.regions.count == 1) + #expect(viewModel.regions.first?.name == "Europe") + #expect(viewModel.regions.first?.floodAllowed == true) + #expect(viewModel.hasUnsavedRegionChanges) + } + + @Test + func `legacy OK put reply still adds the region as flood allowed`() async { + let recorder = CommandRecorder() + recorder.reply = "OK" + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "UK") + + #expect(viewModel.regions.first?.name == "UK") + #expect(viewModel.regions.first?.floodAllowed == true) + } + + @Test + func `empty name is a silent no-op`() async { + let recorder = CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: " ") + + #expect(recorder.commands.isEmpty) + #expect(viewModel.regions.isEmpty) + #expect(viewModel.helper.errorMessage == nil) + #expect(!viewModel.hasUnsavedRegionChanges) + } + + @Test + func `invalid name sets addFailed and does not send`() async { + let recorder = CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "my region") + + #expect(recorder.commands.isEmpty) + #expect(viewModel.regions.isEmpty) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed) + } + + @Test + func `non-OK put reply sets addFailed and does not append`() async { + let recorder = CommandRecorder() + recorder.reply = "Err - unable to put" + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "Europe") + + #expect(recorder.commands == ["region put Europe"]) + #expect(viewModel.regions.isEmpty) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed) + #expect(!viewModel.hasUnsavedRegionChanges) + } +} + +@Suite("RepeaterSettingsViewModel default scope") +@MainActor +struct RepeaterSettingsDefaultScopeTests { + private func makeViewModel(recorder: RepeaterSettingsRegionTests.CommandRecorder) -> RepeaterSettingsViewModel { + let viewModel = RepeaterSettingsViewModel() + viewModel.helper.configure( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ), + sendCommand: recorder.send, + sendRawCommand: recorder.send + ) + return viewModel + } + + @Test + func `parseDefaultScopeReply reads get and set lines`() { + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is ") + == RepeaterSettingsViewModel.ParsedDefaultScope.cleared + ) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is duckburg") + == .named("duckburg") + ) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply("> default scope is now duckburg") + == .named("duckburg") + ) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is now ") + == RepeaterSettingsViewModel.ParsedDefaultScope.cleared + ) + #expect(RepeaterSettingsViewModel.parseDefaultScopeReply("OK") == nil) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is *") + == RepeaterSettingsViewModel.ParsedDefaultScope.cleared + ) + } + + @Test + func `fetchRegions reads default scope after the tree`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F\n duckburg F", + "region default": " default scope is duckburg" + ] + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.fetchRegions() + + #expect(recorder.commands == ["region", "region default"]) + #expect(viewModel.regions.map(\.name) == ["*", "duckburg"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `fetchRegions treats firmware null as no default scope`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F", + "region default": " default scope is " + ] + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.fetchRegions() + + #expect(viewModel.defaultScopeName == nil) + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `unparsed default reply does not fail the regions section`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F", + "region default": "OK" + ] + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.fetchRegions() + + #expect(viewModel.regions.map(\.name) == ["*"]) + #expect(viewModel.defaultScopeName == nil) + #expect(!viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `unparsed default reply keeps the previous default scope`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F\n duckburg F", + "region default": " default scope is duckburg" + ] + let viewModel = makeViewModel(recorder: recorder) + await viewModel.fetchRegions() + + recorder.repliesByCommand["region default"] = "OK" + await viewModel.fetchRegions() + + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `default-scope timeout keeps the previous value and does not fail regions`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F\n duckburg F", + "region default": " default scope is duckburg" + ] + let viewModel = makeViewModel(recorder: recorder) + await viewModel.fetchRegions() + + recorder.errorsByCommand["region default"] = RemoteNodeError.timeout + await viewModel.fetchRegions() + + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `setDefaultScope sends region default and does not mark unsaved`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = " default scope is now duckburg" + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "duckburg", floodAllowed: false, isHome: false) + ] + + await viewModel.setDefaultScope(name: "duckburg") + + #expect(recorder.commands == ["region default duckburg"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(!viewModel.hasUnsavedRegionChanges) + #expect(viewModel.regions.first?.floodAllowed == true) + #expect(viewModel.helper.errorMessage == nil) + } + + @Test + func `setDefaultScope nil sends firmware null token`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = " default scope is now " + let viewModel = makeViewModel(recorder: recorder) + viewModel.defaultScopeName = "duckburg" + + await viewModel.setDefaultScope(name: nil) + + #expect(recorder.commands == ["region default "]) + #expect(viewModel.defaultScopeName == nil) + #expect(!viewModel.hasUnsavedRegionChanges) + } + + @Test + func `setDefaultScope non-matching reply leaves the current value`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = "Err - unknown region" + let viewModel = makeViewModel(recorder: recorder) + viewModel.defaultScopeName = "old" + + await viewModel.setDefaultScope(name: "nope") + + #expect(viewModel.defaultScopeName == "old") + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion) + } + + @Test + func `setDefaultScope does not send wildcard`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.setDefaultScope(name: RepeaterSettingsViewModel.wildcardName) + + #expect(recorder.commands.isEmpty) + #expect(viewModel.defaultScopeName == nil) + } + + @Test + func `setDefaultScope same value is a no-op`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + viewModel.defaultScopeName = "duckburg" + + await viewModel.setDefaultScope(name: "duckburg") + + #expect(recorder.commands.isEmpty) + } + + @Test + func `removeRegion of current default sends remove then default clear`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region remove duckburg": "OK", + "region default ": " default scope is now " + ] + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "*", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "duckburg") + + #expect(recorder.commands == ["region remove duckburg", "region default "]) + #expect(viewModel.regions.map(\.name) == ["*"]) + #expect(viewModel.defaultScopeName == nil) + #expect(viewModel.hasUnsavedRegionChanges) + #expect(viewModel.helper.errorMessage == nil) + #expect(!viewModel.helper.isApplying) + } + + @Test + func `removeRegion of current default keeps the name when trailing clear fails`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region remove duckburg": "OK", + "region default ": "Err - save failed" + ] + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "*", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "duckburg") + + #expect(recorder.commands == ["region remove duckburg", "region default "]) + #expect(viewModel.regions.map(\.name) == ["*"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(viewModel.hasUnsavedRegionChanges) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion) + #expect(!viewModel.helper.isApplying) + } + + @Test + func `removeRegion of current default does not send default clear when remove is rejected`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = "Err - not empty" + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "*", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "duckburg") + + #expect(recorder.commands == ["region remove duckburg"]) + #expect(viewModel.regions.map(\.name) == ["*", "duckburg"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(!viewModel.hasUnsavedRegionChanges) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty) + } + + @Test + func `removeRegion of a different region does not send region default`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = "OK" + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "goosetown", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "goosetown") + + #expect(recorder.commands == ["region remove goosetown"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.regions.map(\.name) == ["duckburg"]) + } +} diff --git a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift index 67fe60337..1c54b4077 100644 --- a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift +++ b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift @@ -242,4 +242,53 @@ struct RepeaterStatusViewModelTests { #expect(viewModel.firmwareVersion == nil, "Nodes predating owner-info return an empty firmware string, which must map to nil") } + + // MARK: - Neighbor key display width capture + + @Test + func `Neighbours response captures the key display width from the device hash size`() async { + let viewModel = RepeaterStatusViewModel() + viewModel.configure( + repeaterAdminService: { nil }, + contactService: { nil }, + nodeSnapshotService: { nil }, + deviceHashSize: { 3 } + ) + + #expect(viewModel.neighborKeyDisplayByteCount == NeighborNameResolver.minimumKeyDisplayByteCount, + "Width should start at the floor before any neighbours response") + + await viewModel.handleNeighboursResponse(createNeighboursResponse()) + + #expect(viewModel.neighborKeyDisplayByteCount == 3, "Width should be captured from the device hash size at fetch") + } + + @Test + func `Captured key display width survives a later disconnect`() async { + let viewModel = RepeaterStatusViewModel() + let device = HashSizeStub(value: 3) + viewModel.configure( + repeaterAdminService: { nil }, + contactService: { nil }, + nodeSnapshotService: { nil }, + deviceHashSize: { device.value } + ) + + await viewModel.handleNeighboursResponse(createNeighboursResponse()) + device.value = nil + + #expect(viewModel.neighborKeyDisplayByteCount == 3, + "A disconnect must not reflow identifiers already on screen") + } +} + +/// Mutable hash-size source for provider closures. A captured local var would trip the +/// sendable-closure mutation warning. +@MainActor +private final class HashSizeStub { + var value: Int? + + init(value: Int?) { + self.value = value + } } diff --git a/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift b/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift index 6d2cb0b26..197299050 100644 --- a/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift +++ b/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift @@ -82,4 +82,129 @@ struct RoomConversationViewModelOrderingTests { #expect(viewModel.messages.map(\.timestamp) == [100, 200]) } + + @Test + func `same-prefix burst books name on first and avatar on last`() { + let alice = Data([0xAA]) + let messages = [ + roomMessage(ts: 100, prefix: alice, name: "Alice"), + roomMessage(ts: 160, prefix: alice, name: "Alice"), + roomMessage(ts: 220, prefix: alice, name: "Alice") + ] + + let bookends = RoomConversationViewModel.incomingBookends(in: messages) + + #expect(bookends.nameIDs == [messages[0].id]) + #expect(bookends.avatarIDs == [messages[2].id]) + } + + @Test + func `different prefixes are two clusters even when display names match`() { + let messages = [ + roomMessage(ts: 100, prefix: Data([0xAA]), name: "Alice"), + roomMessage(ts: 160, prefix: Data([0xBB]), name: "Alice") + ] + + let bookends = RoomConversationViewModel.incomingBookends(in: messages) + + #expect(bookends.nameIDs == [messages[0].id, messages[1].id]) + #expect(bookends.avatarIDs == [messages[0].id, messages[1].id]) + } + + @Test + func `self messages never show a name or avatar`() { + let messages = [ + roomMessage(ts: 100, prefix: Data([0xAA]), name: "Me", isFromSelf: true), + roomMessage(ts: 160, prefix: Data([0xAA]), name: "Me", isFromSelf: true) + ] + + let bookends = RoomConversationViewModel.incomingBookends(in: messages) + + #expect(bookends.nameIDs.isEmpty) + #expect(bookends.avatarIDs.isEmpty) + } + + @Test + func `follow-up flips previous row Hashable identity so the tiled cell reconfigures`() { + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let second = roomMessage(ts: 160, prefix: prefix, name: "Alice") + + let before = RoomConversationViewModel.tiledRows(in: [first]) + let after = RoomConversationViewModel.tiledRows(in: [first, second]) + + #expect(before[0].id == first.id) + #expect(before[0].showAvatar == true) + #expect(after[0].id == first.id) + #expect(after[0].showAvatar == false) + #expect(after[1].showAvatar == true) + #expect(before[0] != after[0]) + } + + @Test + func `mid-insert flips the next row timestamp flag`() { + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let later = roomMessage(ts: 500, prefix: prefix, name: "Alice") + let mid = roomMessage(ts: 250, prefix: prefix, name: "Alice") + + let before = RoomConversationViewModel.tiledRows(in: [first, later]) + #expect(before[1].showTimestamp == true) + + let after = RoomConversationViewModel.tiledRows(in: [first, mid, later]) + #expect(after[2].id == later.id) + #expect(after[2].showTimestamp == false) + #expect(before[1] != after[2]) + } + + @Test + func `300s same-prefix is one cluster; 301s is two`() { + let prefix = Data([0xAA]) + let a = roomMessage(ts: 1000, prefix: prefix, name: "Alice") + let at300 = roomMessage(ts: 1300, prefix: prefix, name: "Alice") + let at301 = roomMessage(ts: 1301, prefix: prefix, name: "Alice") + + let clustered = RoomConversationViewModel.tiledRows(in: [a, at300]) + #expect(clustered[0].showSenderName == true) + #expect(clustered[0].showAvatar == false) + #expect(clustered[1].showSenderName == false) + #expect(clustered[1].showAvatar == true) + + let split = RoomConversationViewModel.tiledRows(in: [a, at301]) + #expect(split[0].showAvatar == true) + #expect(split[1].showSenderName == true) + #expect(split[1].showAvatar == true) + } + + @Test + func `self message breaks an incoming prefix cluster`() { + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let me = roomMessage(ts: 160, prefix: prefix, name: "Alice", isFromSelf: true) + let third = roomMessage(ts: 220, prefix: prefix, name: "Alice") + + let rows = RoomConversationViewModel.tiledRows(in: [first, me, third]) + #expect(rows[0].showSenderName == true) + #expect(rows[0].showAvatar == true) + #expect(rows[1].showSenderName == false) + #expect(rows[1].showAvatar == false) + #expect(rows[2].showSenderName == true) + #expect(rows[2].showAvatar == true) + } + + private func roomMessage( + ts: UInt32, + prefix: Data, + name: String, + isFromSelf: Bool = false + ) -> RoomMessageDTO { + RoomMessageDTO( + sessionID: sessionID, + authorKeyPrefix: prefix, + authorName: name, + text: "msg", + timestamp: ts, + isFromSelf: isFromSelf + ) + } } diff --git a/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift new file mode 100644 index 000000000..42d965c5b --- /dev/null +++ b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift @@ -0,0 +1,193 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("RoomConversationViewModel translation") +@MainActor +struct RoomConversationViewModelTranslationTests { + private let german = "Guten Morgen, wie geht es dir heute?" + private let sessionID = UUID() + private let showingEnglish = MessageTranslationChrome.Phase.showing( + translatedText: "Hello", + targetLanguageCode: "en" + ) + + @Test + func `translation-only change makes RoomTiledRow unequal`() { + let message = roomMessage(text: german) + let without = RoomTiledRow( + message: message, + showTimestamp: true, + showSenderName: true, + showAvatar: true, + translation: nil + ) + let withOffer = RoomTiledRow( + message: message, + showTimestamp: true, + showSenderName: true, + showAvatar: true, + translation: MessageTranslationChrome(phase: .offer, sourceLanguageCode: "de") + ) + #expect(without != withOffer) + } + + @Test + func `from-self foreign-language message has no translation chrome`() throws { + let viewModel = RoomConversationViewModel() + viewModel.preferredLanguageCode = "en" + let message = roomMessage(text: german, isFromSelf: true) + viewModel.appendMessageIfNew(message) + + let row = try #require(viewModel.tiledRows.first) + #expect(row.translation == nil) + #expect(viewModel.detectedLanguages[message.id] == nil) + } + + @Test + func `detector maps land chrome on the materialized row`() throws { + let viewModel = RoomConversationViewModel() + viewModel.preferredLanguageCode = "en" + viewModel.appendMessageIfNew(roomMessage(text: german)) + + let row = try #require(viewModel.tiledRows.first) + #expect(row.translation?.phase == .offer) + #expect(row.translation?.sourceLanguageCode == "de") + } + + @Test + func `toggle and fake translator show then restore without writing stored text`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + + viewModel.performTranslationAction(for: message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == .inProgress) + try await performCurrent(viewModel, using: translator) + + #expect(viewModel.tiledRows.first?.translation?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + #expect(viewModel.messages.first?.text == german) + #expect(viewModel.errorMessage == nil) + + viewModel.performTranslationAction(for: message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + #expect(viewModel.messages.first?.text == german) + + viewModel.performTranslationAction(for: message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == showingEnglish) + #expect(translator.translateCount == 1) + } + + @Test + func `needs download leaves the in-flight request for the system sheet`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(MessageTranslationNeedsDownloadError()) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + + #expect(result == .needsDownload) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translationSessionRequest?.messageID == message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == .inProgress) + } + + @Test + func `translator throw presents system overlay and returns to offer`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(RoomTranslationTestError.failed) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + + #expect(result == .presentSystemOverlay(text: german)) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.messages.first?.text == german) + } + + @Test + func `cancellation leaves offer without errorMessage`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(CancellationError()) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + + #expect(result == .finished) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + } + + @Test + func `download UI cancel leaves offer without errorMessage`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(CocoaError(.userCancelled)) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + + #expect(result == .finished) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.messages.first?.text == german) + } + + @discardableResult + private func performCurrent( + _ viewModel: RoomConversationViewModel, + using translator: any MessageTranslating + ) async throws -> TranslationPerformResult { + let request = try #require(viewModel.translationSessionRequest) + return await viewModel.performPendingTranslation(using: translator, for: request) + } + + private func seededGermanRoom() -> RoomConversationViewModel { + let viewModel = RoomConversationViewModel() + viewModel.preferredLanguageCode = "en" + viewModel.appendMessageIfNew(roomMessage(text: german)) + return viewModel + } + + private func roomMessage( + text: String, + timestamp: UInt32 = 100, + isFromSelf: Bool = false + ) -> RoomMessageDTO { + RoomMessageDTO( + sessionID: sessionID, + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: text, + timestamp: timestamp, + isFromSelf: isFromSelf + ) + } +} + +private enum RoomTranslationTestError: Error { + case failed +} + +@MainActor +private final class CountingMessageTranslator: MessageTranslating { + var translateCount = 0 + var result: Result = .success("Hello") + + func translate(_: String) async throws -> String { + translateCount += 1 + return try result.get() + } +} diff --git a/MC1Tests/ViewModels/RxLogViewModelTests.swift b/MC1Tests/ViewModels/RxLogViewModelTests.swift index d3ef201b8..f0a5ee46e 100644 --- a/MC1Tests/ViewModels/RxLogViewModelTests.swift +++ b/MC1Tests/ViewModels/RxLogViewModelTests.swift @@ -265,6 +265,7 @@ struct RxLogViewModelTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift index 2b0241e82..7ec354668 100644 --- a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift +++ b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift @@ -34,6 +34,7 @@ struct TelemetryHistoryOverviewViewModelTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/TracePathListenerTests.swift b/MC1Tests/ViewModels/TracePathListenerTests.swift index 22d1ba923..07d66589f 100644 --- a/MC1Tests/ViewModels/TracePathListenerTests.swift +++ b/MC1Tests/ViewModels/TracePathListenerTests.swift @@ -19,7 +19,7 @@ struct TracePathListenerTests { private func makeServices() throws -> ServiceContainer { try ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: PersistenceStore.createContainer(inMemory: true), + dataStore: PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)), radioID: UUID() ) } diff --git a/MC1Tests/ViewModels/TracePathViewModelTests.swift b/MC1Tests/ViewModels/TracePathViewModelTests.swift index 5a3045fbd..4787d4d47 100644 --- a/MC1Tests/ViewModels/TracePathViewModelTests.swift +++ b/MC1Tests/ViewModels/TracePathViewModelTests.swift @@ -40,7 +40,8 @@ private func createTestContact() -> ContactDTO { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) return ContactDTO(from: contact) } @@ -880,7 +881,8 @@ struct PathCaptureTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) viewModel.addNode(ContactDTO(from: contact)) @@ -906,7 +908,8 @@ struct PathCaptureTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) viewModel.addNode(ContactDTO(from: contact)) @@ -1310,7 +1313,8 @@ struct CodeInputParsingTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) return ContactDTO(from: contact) } @@ -1506,6 +1510,7 @@ struct OutboundPathNameResolutionTests { latitude: lat, longitude: lon, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1538,6 +1543,7 @@ struct OutboundPathNameResolutionTests { latitude: contact1.latitude, longitude: contact1.longitude, lastModified: contact1.lastModified, + lastHeardTimestamp: nil, nickname: contact1.nickname, isBlocked: contact1.isBlocked, isMuted: contact1.isMuted, @@ -1558,6 +1564,7 @@ struct OutboundPathNameResolutionTests { latitude: contact2.latitude, longitude: contact2.longitude, lastModified: contact2.lastModified, + lastHeardTimestamp: nil, nickname: contact2.nickname, isBlocked: contact2.isBlocked, isMuted: contact2.isMuted, @@ -1625,6 +1632,7 @@ struct OutboundPathNameResolutionTests { latitude: 37.0, longitude: -122.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1645,6 +1653,7 @@ struct OutboundPathNameResolutionTests { latitude: 38.0, longitude: -123.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1734,7 +1743,8 @@ struct RoomSupportTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) return ContactDTO(from: contact) } diff --git a/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift b/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift index 1f82f016d..8900f8752 100644 --- a/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift +++ b/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift @@ -204,6 +204,7 @@ struct ChannelInfoRegionQueryTargetsTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatConversationTypeTests.swift b/MC1Tests/Views/Chats/ChatConversationTypeTests.swift index 9e1b49135..140906c18 100644 --- a/MC1Tests/Views/Chats/ChatConversationTypeTests.swift +++ b/MC1Tests/Views/Chats/ChatConversationTypeTests.swift @@ -25,6 +25,7 @@ struct ChatConversationTypeTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, @@ -275,6 +276,7 @@ struct ChatConversationTypeTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatConversationViewTests.swift b/MC1Tests/Views/Chats/ChatConversationViewTests.swift index 084db8ba8..6ac4786e4 100644 --- a/MC1Tests/Views/Chats/ChatConversationViewTests.swift +++ b/MC1Tests/Views/Chats/ChatConversationViewTests.swift @@ -24,6 +24,7 @@ struct ResolveMentionTapTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatReconnectPopulateTests.swift b/MC1Tests/Views/Chats/ChatReconnectPopulateTests.swift new file mode 100644 index 000000000..ee1b42b16 --- /dev/null +++ b/MC1Tests/Views/Chats/ChatReconnectPopulateTests.swift @@ -0,0 +1,996 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// Reconnect must refresh the open timeline in place instead of replacing it. +@Suite("Chat reconnect populate", .serialized) +@MainActor +struct ChatReconnectPopulateTests { + @Test + func `refreshWindow keeps the paged-in window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: ChatCoordinator.pageSize + 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + let hasMoreBefore = env.viewModel.hasMoreMessages + #expect(countBefore > ChatCoordinator.pageSize) + #expect(hasMoreBefore) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + + #expect(loaded) + await env.viewModel.coordinator?.buildItemsTask?.value + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.messages.map(\.id) == env.viewModel.renderState.items.map(\.id)) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + #expect(env.viewModel.hasMoreMessages == hasMoreBefore) + } + + @Test + func `refreshWindow load still clears unread and returns success`() async throws { + let env = try await makeLoadedEnvironment(unreadCount: 4) + #expect(env.viewModel.messages.isEmpty == false) + + await env.viewModel.loadMessages(for: env.contact, populateMode: .refreshWindow) + #expect(env.viewModel.errorMessage == nil) + + let updated = try await env.dataStore.fetchContact(id: env.contact.id) + #expect(updated?.unreadCount == 0) + } + + @Test + func `replace mode replaces the paged-in window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + #expect(env.viewModel.messages.count > ChatCoordinator.pageSize) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .replace + ) + + #expect(loaded) + #expect(env.viewModel.messages.count == ChatCoordinator.pageSize) + #expect(!env.viewModel.messages.contains { $0.id == oldestID }) + } + + @Test + func `refreshWindow clears a stuck isLoadingOlder`() async throws { + let env = try await makeLoadedEnvironment() + env.viewModel.timeline.writer?.updateRenderState { $0.with(isLoadingOlder: true) } + #expect(env.viewModel.isLoadingOlder) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + + #expect(loaded) + #expect(env.viewModel.isLoadingOlder == false) + #expect(env.viewModel.totalFetchedCount == ChatCoordinator.pageSize) + } + + @Test + func `first open of a primed coordinator runs populate and bakes the divider`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let unread = 3 + let contact = makeContact(radioID: radioID, unreadCount: unread) + try await dataStore.saveContact(contact) + for index in 0..<(ChatCoordinator.pageSize + unread) { + try await dataStore.saveMessage( + makeMessage( + radioID: radioID, + contactID: contact.id, + timestamp: UInt32(1000 + index) + ) + ) + } + + let coordinator = ChatCoordinator( + conversationID: .dm(radioID: radioID, contactID: contact.id), + dataStore: dataStore + ) + + // Warm the coordinator the way the navigation prefetch does: another + // writer populates it before the view's own open has run. + let primer = ChatViewModel() + primer.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + primer.bindCoordinatorForTesting(coordinator) + #expect(await primer.primeInitialMessages(for: contact, populateMode: .replace)) + + let viewModel = ChatViewModel() + viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + viewModel.bindCoordinatorForTesting(coordinator) + viewModel.timeline.stageOpen(.dm(contact)) + #expect(viewModel.timeline.initialLoadSettled == false) + + let loaded = await viewModel.primeInitialMessages(for: contact, populateMode: .refreshWindow) + + #expect(loaded) + #expect(viewModel.timeline.initialLoadSettled, + "First open must run populate, not skip the warm window") + let dividerID = try #require(viewModel.bake.newMessagesDividerMessageID, + "The interactive open must bake the New Messages divider") + await coordinator.buildItemsTask?.value + #expect(viewModel.timeline.firstSnapshot == .present(target: dividerID), + "A staged open with unread must present at the divider, not withhold") + } + + @Test + func `never-paired conversation open is unavailable`() async { + let appState = AppState() + defer { appState.shutdown() } + appState.connectionManager.testForceNeverPaired = true + + #expect(appState.connectionManager.lastConnectedDeviceID == nil) + #expect(appState.offlineDataStore == nil) + #expect(appState.ensureChatCoordinatorRegistry() == nil) + #expect(appState.chatCoordinatorRegistry == nil) + + let contact = makeContact(radioID: UUID(), unreadCount: 0) + let viewModel = ChatViewModel() + viewModel.configure( + dependencies: appState.makeChatViewModelDependencies(), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: appState.ensureChatCoordinatorRegistry(), + conversation: .dm(contact) + ) + viewModel.applyEnvInputs(.default) + + let loaded = await viewModel.primeInitialMessages( + for: contact, + populateMode: .refreshWindow + ) + #expect(loaded == false) + #expect(appState.chatCoordinatorRegistry == nil) + #expect(viewModel.renderState.phase == .uninitialized) + } + + @Test + func `catch-up delivery admits once after refreshWindow`() async throws { + let env = try await makeLoadedEnvironment() + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(loaded) + + let incoming = makeMessage( + radioID: env.contact.radioID, + contactID: env.contact.id, + timestamp: 9000 + ) + try await env.dataStore.saveMessage(incoming) + + await env.viewModel.handle(.directMessageReceived(message: incoming, contact: env.contact)) + #expect(env.viewModel.messages.contains { $0.id == incoming.id }) + let countAfterAdmit = env.viewModel.messages.count + + await env.viewModel.handle(.directMessageReceived(message: incoming, contact: env.contact)) + #expect(env.viewModel.messages.count == countAfterAdmit) + } + + @Test + func `refreshWindow keeps the paged-in channel window and sender tables`() async throws { + let env = try await makeLoadedChannelEnvironment(extraOlderCount: ChatCoordinator.pageSize + 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + let hasMoreBefore = env.viewModel.hasMoreMessages + let senderNamesBefore = env.viewModel.channelSenderNames + let senderOrderBefore = env.viewModel.channelSenderOrder + let oldestLoadedSender = try #require(env.viewModel.messages.first?.senderNodeName) + #expect(countBefore > ChatCoordinator.pageSize) + #expect(hasMoreBefore) + #expect(senderNamesBefore.contains(oldestLoadedSender)) + + let loaded = await env.viewModel.primeInitialChannelMessages( + for: env.channel, + populateMode: .refreshWindow + ) + + #expect(loaded) + await env.viewModel.coordinator?.buildItemsTask?.value + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.messages.map(\.id) == env.viewModel.renderState.items.map(\.id)) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + #expect(env.viewModel.hasMoreMessages == hasMoreBefore) + #expect(env.viewModel.channelSenderNames == senderNamesBefore) + #expect(env.viewModel.channelSenderOrder == senderOrderBefore) + } + + @Test + func `replace mode replaces the paged-in channel window`() async throws { + let env = try await makeLoadedChannelEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + #expect(env.viewModel.messages.count > ChatCoordinator.pageSize) + #expect(env.viewModel.channelSenderNames.contains(Self.oldestChannelSenderName)) + + let loaded = await env.viewModel.primeInitialChannelMessages( + for: env.channel, + populateMode: .replace + ) + + #expect(loaded) + #expect(env.viewModel.messages.count == ChatCoordinator.pageSize) + #expect(!env.viewModel.messages.contains { $0.id == oldestID }) + #expect(!env.viewModel.channelSenderNames.contains(Self.oldestChannelSenderName)) + } + + @Test + func `refreshWindow reconciles a silent store status rewrite`() async throws { + let env = try await makeLoadedEnvironment() + let target = try #require(env.viewModel.messages.last) + #expect(target.status == .delivered) + + try await env.dataStore.updateMessageStatus(id: target.id, status: .failed) + #expect(env.viewModel.messagesByID[target.id]?.status == .delivered) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + + #expect(loaded) + #expect(env.viewModel.messagesByID[target.id]?.status == .failed) + } + + @Test + func `refreshWindow reindexes reactions on a fresh ReactionService`() async throws { + let env = try await makeLoadedEnvironment() + let target = try #require(env.viewModel.messages.last) + let freshReactions = ReactionService() + env.viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { env.dataStore }, + reactionService: { freshReactions } + ) + ) + try env.viewModel.bindCoordinatorForTesting(#require(env.viewModel.coordinator)) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(loaded) + + let hash = ReactionParser.generateMessageHash( + text: target.text, + timestamp: target.reactionTimestamp + ) + let found = await freshReactions.findDMTargetMessage( + messageHash: hash, + contactID: env.contact.id + ) + #expect(found == target.id) + } + + @Test + func `failed populate then refreshWindow retries with the intact window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + #expect(countBefore > ChatCoordinator.pageSize) + + env.viewModel.timeline.testPopulateError = PopulateTestError.fetchFailed + defer { env.viewModel.timeline.testPopulateError = nil } + + let failed = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(failed == false) + #expect(env.viewModel.errorMessage != nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + + env.viewModel.timeline.testPopulateError = nil + + let retried = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(retried) + #expect(env.viewModel.errorMessage == nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + } + + @Test + func `populate catch keeps counters so refreshWindow retries the full window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + #expect(countBefore > ChatCoordinator.pageSize) + + env.viewModel.timeline.testPopulateFetchError = PopulateTestError.fetchFailed + defer { env.viewModel.timeline.testPopulateFetchError = nil } + + let failed = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(failed == false) + #expect(env.viewModel.errorMessage != nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + + env.viewModel.timeline.testPopulateFetchError = nil + + let retried = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(retried) + #expect(env.viewModel.errorMessage == nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + } + + @Test + func `cancelled populate keeps counters and does not set errorMessage`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + + env.viewModel.timeline.testPopulateFetchError = CancellationError() + defer { env.viewModel.timeline.testPopulateFetchError = nil } + + let cancelled = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(cancelled == false) + #expect(env.viewModel.errorMessage == nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + } + + @Test + func `refreshWindow after newer store rows reports hasMoreMessages`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: ChatCoordinator.pageSize + 12) + #expect(env.viewModel.hasMoreMessages == true) + #expect(env.viewModel.totalFetchedCount == ChatCoordinator.pageSize * 2) + + let newestLoaded = try #require(env.viewModel.messages.last?.timestamp) + for index in 0.. ChatCoordinator.pageSize) + + coordinator.hardReset(reason: "test") + await coordinator.hardResetTask?.value + + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.hasMoreMessages == true) + + await env.viewModel.loadOlderMessages() + + let expectedCount = ChatCoordinator.pageSize + extraOlderCount + #expect(env.viewModel.messages.count == expectedCount) + #expect(env.viewModel.totalFetchedCount == expectedCount) + let timestamps = env.viewModel.messages.map(\.timestamp) + #expect(timestamps == timestamps.sorted()) + #expect(env.viewModel.messages.first?.timestamp == 1000) + } + + @Test + func `refreshWindow after hidden outgoing reactions retains the oldest row`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let visibleCountBefore = env.viewModel.messages.count + let newestLoaded = try #require(env.viewModel.messages.last?.timestamp) + + let hiddenReactionCount = 2 + for index in 0.. LoadedEnvironment { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID, unreadCount: unreadCount) + try await dataStore.saveContact(contact) + + let total = ChatCoordinator.pageSize + extraOlderCount + for index in 0.. 0 { + await viewModel.loadOlderMessages() + } + return LoadedEnvironment(dataStore: dataStore, viewModel: viewModel, contact: contact) + } + + private struct LoadedChannelEnvironment { + let viewModel: ChatViewModel + let channel: ChannelDTO + } + + private func makeLoadedChannelEnvironment( + extraOlderCount: Int = 0 + ) async throws -> LoadedChannelEnvironment { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channel = makeChannel(radioID: radioID) + try await dataStore.saveChannel(channel) + + let total = ChatCoordinator.pageSize + extraOlderCount + for index in 0.. 0 { + await viewModel.loadOlderMessages() + } + return LoadedChannelEnvironment(viewModel: viewModel, channel: channel) + } +} + +private func makeContact(radioID: UUID, unreadCount: Int) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. ChannelDTO { + ChannelDTO( + id: UUID(), + radioID: radioID, + index: 3, + name: "General", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) +} + +private func makeChannelMessage( + radioID: UUID, + channelIndex: UInt8, + timestamp: UInt32, + senderName: String +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: "channel \(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 makeOutgoingReactionMessage( + radioID: UUID, + contactID: UUID, + timestamp: UInt32 +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "👍\nABCDEFGH", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .outgoing, + status: .sent, + 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 makeMessage(radioID: UUID, contactID: UUID, timestamp: UInt32) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "message \(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 + ) +} + +/// Parks `loadOlder` between its fetch and first write. +private actor AsyncGate { + private var waiter: CheckedContinuation? + private var opened = false + + func wait() async { + if opened { return } + await withCheckedContinuation { waiter = $0 } + } + + func open() { + opened = true + waiter?.resume() + waiter = nil + } +} diff --git a/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift index 79a8e8c72..cd459970e 100644 --- a/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift @@ -189,9 +189,10 @@ struct ChatTimelineClobberRegressionTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 )) - _ = await viewModel.primeInitialMessages(for: contact) + _ = await viewModel.primeInitialMessages(for: contact, populateMode: .replace) #expect(!fetchStandIn.isCancelled) #expect(viewModel.bake.previewStates[message.id] == .loading) @@ -212,9 +213,10 @@ struct ChatTimelineClobberRegressionTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 )) - _ = await viewModel.primeInitialMessages(for: otherContact) + _ = await viewModel.primeInitialMessages(for: otherContact, populateMode: .replace) #expect(viewModel.bake.previewStates[message.id] == nil) } diff --git a/MC1Tests/Views/Chats/ChatTimelineTests.swift b/MC1Tests/Views/Chats/ChatTimelineTests.swift index 16abfd326..a7ca27505 100644 --- a/MC1Tests/Views/Chats/ChatTimelineTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineTests.swift @@ -30,6 +30,7 @@ struct ChatTimelineTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -87,7 +88,7 @@ struct ChatTimelineTests { func `open on an unbound timeline reports unavailable`() async { let timeline = ChatTimeline(role: .interactive) let contact = makeContact(radioID: UUID()) - let outcome = await timeline.open(.dm(contact), reactions: nil) + let outcome = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) guard case .unavailable = outcome else { Issue.record("expected .unavailable, got \(outcome)") return @@ -111,7 +112,7 @@ struct ChatTimelineTests { dataStore: dataStore, conversationID: .dm(radioID: radioID, contactID: contact.id) ) - let outcome = await timeline.open(.dm(contact), reactions: nil) + let outcome = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) guard case .loaded = outcome else { Issue.record("expected .loaded, got \(outcome)") @@ -119,7 +120,34 @@ struct ChatTimelineTests { } #expect(timeline.messages.count == ChatCoordinator.pageSize) #expect(timeline.messages.first?.text == "m10") - #expect(timeline.renderState.hasMoreMessages) + #expect(timeline.renderState.hasMoreMessages == true) + } + + @Test + func `open of exactly one page reports no further history`() async throws { + let dataStore = try makeStore() + let radioID = UUID() + let contact = makeContact(radioID: radioID) + for offset in 0.. (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } @@ -485,6 +503,11 @@ private actor AdmissionStubDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} @@ -736,7 +759,7 @@ private actor AdmissionStubDataStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -744,10 +767,17 @@ private actor AdmissionStubDataStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} diff --git a/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift b/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift index 76abbd51e..e1eac9132 100644 --- a/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift @@ -26,6 +26,7 @@ struct ChatViewModelConversationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift b/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift index 8a41895a4..5d567e649 100644 --- a/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift @@ -33,6 +33,7 @@ struct ChatViewModelDeleteSequencingTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatViewModelFailedSendTests.swift b/MC1Tests/Views/Chats/ChatViewModelFailedSendTests.swift new file mode 100644 index 000000000..3c5a5123e --- /dev/null +++ b/MC1Tests/Views/Chats/ChatViewModelFailedSendTests.swift @@ -0,0 +1,643 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("ChatViewModel failed-send indicators") +@MainActor +struct ChatViewModelFailedSendTests { + private func makeViewModel() -> ChatViewModel { + ChatViewModel() + } + + private func makeContact( + id: UUID = UUID(), + radioID: UUID = UUID(), + lastMessageDate: Date? = Date() + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: radioID, + publicKey: Data(repeating: UInt8(truncatingIfNeeded: id.hashValue), count: 32), + name: "Test", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: lastMessageDate, + unreadCount: 0 + ) + } + + private func makeChannel( + id: UUID = UUID(), + radioID: UUID = UUID(), + index: UInt8 = 0 + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: "General", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + } + + private func makeRoom( + id: UUID = UUID(), + radioID: UUID = UUID() + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, + radioID: radioID, + publicKey: Data(repeating: UInt8(truncatingIfNeeded: id.hashValue), count: 32), + name: "Room", + role: .roomServer, + isConnected: true, + isFavorite: false, + lastMessageDate: Date() + ) + } + + private func makeMessage( + radioID: UUID, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + text: String = "hello", + status: MessageStatus = .failed + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: 1_700_000_000, + createdAt: Date(), + direction: .outgoing, + status: status, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + @Test + func `applyFailedSendKeys maps contact id onto the list set`() { + let viewModel = makeViewModel() + let contact = makeContact() + viewModel.conversations = [contact] + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + + #expect(viewModel.failedSendConversationIDs == [contact.id]) + #expect(viewModel.conversationHasFailedSend(contact.id)) + #expect(!viewModel.conversationHasFailedSend(UUID())) + } + + @Test + func `applyFailedSendKeys unions channel ids`() { + let viewModel = makeViewModel() + let channel = makeChannel(index: 4) + let other = makeChannel(index: 7) + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(channelIDs: [channel.id])) + + #expect(viewModel.failedSendConversationIDs == [channel.id]) + #expect(viewModel.conversationHasFailedSend(channel.id)) + #expect(!viewModel.conversationHasFailedSend(other.id)) + } + + @Test + func `applyFailedSendKeys maps room session id`() { + let viewModel = makeViewModel() + let session = makeRoom() + viewModel.roomSessions = [session] + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(roomSessionIDs: [session.id])) + + #expect(viewModel.failedSendConversationIDs == [session.id]) + #expect(viewModel.conversationHasFailedSend(session.id)) + } + + @Test + func `clearConversations empties the failed-send set`() { + let viewModel = makeViewModel() + let contact = makeContact() + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + #expect(!viewModel.failedSendConversationIDs.isEmpty) + + viewModel.clearConversations() + + #expect(viewModel.failedSendConversationIDs.isEmpty) + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `refresh with empty keys clears a previously populated set`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.failedSendConversationIDs.isEmpty) + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `refresh loads failed DM channel and room keys from the store`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let channel = makeChannel(radioID: radioID, index: 2) + let session = makeRoom(radioID: radioID) + try await store.saveChannel(channel) + try await store.saveRemoteNodeSessionDTO(session) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + try await store.saveMessage( + makeMessage(radioID: radioID, channelIndex: channel.index, status: .failed) + ) + try await store.saveRoomMessage( + RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0xAB, 0xCD, 0xEF, 0x01]), + text: "failed room send", + timestamp: 1_700_000_000, + isFromSelf: true, + status: .failed + ) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.channels = [channel] + viewModel.roomSessions = [session] + + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.conversationHasFailedSend(contact.id)) + #expect(viewModel.conversationHasFailedSend(channel.id)) + #expect(viewModel.conversationHasFailedSend(session.id)) + } + + @Test + func `conversation reload loads failed-send indicators`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID, lastMessageDate: Date()) + try await store.saveContact(contact) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + + await viewModel.requestConversationReload()?.value + + #expect(viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `refresh with no radio id clears the set`() async { + let viewModel = makeViewModel() + let contact = makeContact() + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { nil }, + currentRadioID: { nil } + ) + ) + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.failedSendConversationIDs.isEmpty) + } + + @Test + func `messageFailed and peers always refresh`() { + let viewModel = makeViewModel() + let messageID = UUID() + + #expect(viewModel.shouldRefreshFailedSendIndicators(for: .messageFailed(messageID: messageID))) + #expect(viewModel.shouldRefreshFailedSendIndicators(for: .messageResent(messageID: messageID))) + #expect(viewModel.shouldRefreshFailedSendIndicators(for: .roomMessageFailed(messageID: messageID))) + } + + @Test + func `messageStatusResolved refreshes only when a badge is showing`() { + let viewModel = makeViewModel() + let event = MessageEvent.messageStatusResolved(messageID: UUID(), status: .delivered) + + #expect(!viewModel.shouldRefreshFailedSendIndicators(for: event)) + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [UUID()])) + + #expect(viewModel.shouldRefreshFailedSendIndicators(for: event)) + } + + @Test + func `roomMessageStatusUpdated refreshes only when a badge is showing`() { + let viewModel = makeViewModel() + let event = MessageEvent.roomMessageStatusUpdated(messageID: UUID()) + + #expect(!viewModel.shouldRefreshFailedSendIndicators(for: event)) + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(roomSessionIDs: [UUID()])) + + #expect(viewModel.shouldRefreshFailedSendIndicators(for: event)) + } + + @Test + func `other message events do not refresh`() { + let viewModel = makeViewModel() + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [UUID()])) + let messageID = UUID() + let contact = makeContact() + let message = makeMessage(radioID: contact.radioID, contactID: contact.id) + + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .directMessageReceived(message: message, contact: contact) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .channelMessageReceived(message: message, channelIndex: 0) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .roomMessageReceived( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + text: "hi", + timestamp: 1 + ), + sessionID: UUID() + ) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .messageRetrying(messageID: messageID, attempt: 1, maxAttempts: 4) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .heardRepeatRecorded(messageID: messageID, count: 1) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .reactionReceived(messageID: messageID, summary: "👍:1") + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .messagesRegionUpdated(messageIDs: [messageID]) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .routingChanged(contactID: contact.id, isFlood: true) + )) + } + + /// Refresh #1 fetches empty keys and parks; #2 then fetches the saved failed + /// send and applies it. #1 must drop its empty result instead of wiping #2. + @Test + func `stale empty refresh does not overwrite a newer apply`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + + let arrived = AsyncGate() + let gate = AsyncGate() + viewModel.failedSendRefreshInterleaveHook = { + await arrived.open() + await gate.wait() + } + + let first = Task { await viewModel.refreshFailedSendIndicators() } + await arrived.wait() + + viewModel.failedSendRefreshInterleaveHook = nil + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await gate.open() + await first.value + + #expect(viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `cancelled in-flight refresh does not apply fetched keys`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + + let arrived = AsyncGate() + let gate = AsyncGate() + viewModel.failedSendRefreshInterleaveHook = { + await arrived.open() + await gate.wait() + } + + let task = Task { await viewModel.refreshFailedSendIndicators() } + await arrived.wait() + task.cancel() + await gate.open() + await task.value + + #expect(viewModel.failedSendConversationIDs.isEmpty) + } + + @Test + func `store throw leaves last-known badges`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + viewModel.failedSendRefreshFaultInjection = { throw TestStoreError() } + + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.conversationHasFailedSend(contact.id)) + } + + /// Event-driven refresh is not `reloadTask`; clear must bump generation so a + /// parked fetch cannot re-dirty `failedSendConversationIDs` after disconnect. + @Test + func `clearConversations drops an in-flight refresh apply`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + + let arrived = AsyncGate() + let gate = AsyncGate() + viewModel.failedSendRefreshInterleaveHook = { + await arrived.open() + await gate.wait() + } + + let task = Task { await viewModel.refreshFailedSendIndicators() } + await arrived.wait() + viewModel.clearConversations() + #expect(viewModel.failedSendConversationIDs.isEmpty) + + await gate.open() + await task.value + + #expect(viewModel.failedSendConversationIDs.isEmpty) + } + + @Test + func `loadMessages marks failed sends seen; prime does not`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + try await store.saveContact(contact) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + viewModel.conversations = [contact] + + _ = await viewModel.primeInitialMessages(for: contact, populateMode: .replace) + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await viewModel.loadMessages(for: contact, populateMode: .replace) + await viewModel.refreshFailedSendIndicators() + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `loadChannelMessages marks failed sends seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channel = makeChannel(radioID: radioID, index: 3) + try await store.saveChannel(channel) + try await store.saveMessage( + makeMessage(radioID: radioID, channelIndex: channel.index, status: .failed) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + viewModel.channels = [channel] + + await viewModel.loadChannelMessages(for: channel, populateMode: .replace) + await viewModel.refreshFailedSendIndicators() + #expect(!viewModel.conversationHasFailedSend(channel.id)) + } + + @Test + func `handle messageFailed for the current contact marks seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let message = makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + try await store.saveContact(contact) + try await store.saveMessage(message) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.currentContact = contact + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await viewModel.handle(.messageFailed(messageID: message.id)) + await viewModel.refreshFailedSendIndicators() + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `handle messageFailed for another contact does not mark seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let openContact = makeContact(radioID: radioID) + let otherContact = makeContact(radioID: radioID) + let message = makeMessage(radioID: radioID, contactID: otherContact.id, status: .failed) + try await store.saveContact(otherContact) + try await store.saveMessage(message) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [openContact, otherContact] + viewModel.currentContact = openContact + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(otherContact.id)) + + await viewModel.handle(.messageFailed(messageID: message.id)) + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(otherContact.id)) + } + + @Test + func `recordLocalEnqueueFailure marks the current contact seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let message = makeMessage(radioID: radioID, contactID: contact.id, status: .pending) + try await store.saveContact(contact) + try await store.saveMessage(message) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.currentContact = contact + + await viewModel.recordLocalEnqueueFailure( + messageID: message.id, + error: ChatSendQueueServiceError.notConnected + ) + await viewModel.refreshFailedSendIndicators() + + #expect(!viewModel.conversationHasFailedSend(contact.id)) + #expect(viewModel.sendErrorMessage == L10n.Chats.Chats.Alert.UnableToSend.message) + let stored = try await store.fetchMessage(id: message.id) + #expect(stored?.status == .failed) + #expect(stored?.failureSeen == true) + } +} + +private struct TestStoreError: Error {} + +/// Parks a failed-send refresh between its fetch and apply. +private actor AsyncGate { + private var waiter: CheckedContinuation? + private var opened = false + + func wait() async { + if opened { return } + await withCheckedContinuation { waiter = $0 } + } + + func open() { + opened = true + waiter?.resume() + waiter = nil + } +} diff --git a/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift b/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift index 10b007e0b..f4d9e9b1d 100644 --- a/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift @@ -19,6 +19,7 @@ private func makeContact(radioID: UUID, name: String = "Alice") -> ContactDTO { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift b/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift index e643efda4..8454a3cda 100644 --- a/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift @@ -34,6 +34,7 @@ struct ChatViewModelReloadSerializationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift b/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift index f79c8b8d6..8b61ab994 100644 --- a/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift +++ b/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift @@ -130,4 +130,34 @@ struct ChatKeyboardLiftTests { ) #expect(animation == nil) } + + @Test + func `interactive pop keeps current lift on a drop`() { + let resolved = ChatKeyboardLift.resolvedLift( + current: 302, + proposed: 0, + isInteractivePopActive: true + ) + #expect(resolved == 302) + } + + @Test + func `hide without interactive pop applies proposed lift`() { + let resolved = ChatKeyboardLift.resolvedLift( + current: 302, + proposed: 0, + isInteractivePopActive: false + ) + #expect(resolved == 0) + } + + @Test + func `interactive pop still applies a higher lift`() { + let resolved = ChatKeyboardLift.resolvedLift( + current: 200, + proposed: 302, + isInteractivePopActive: true + ) + #expect(resolved == 302) + } } diff --git a/MC1Tests/Views/Chats/Components/ChatTiledViewScrollRequestTests.swift b/MC1Tests/Views/Chats/Components/ChatTiledViewScrollRequestTests.swift new file mode 100644 index 000000000..2986fec8f --- /dev/null +++ b/MC1Tests/Views/Chats/Components/ChatTiledViewScrollRequestTests.swift @@ -0,0 +1,335 @@ +@testable import MC1 +import SwiftUI +import Testing +import UIKit + +/// Holds bindings for a hosted `ChatTiledView` so mutations do not remount it. +@Observable +@MainActor +private final class ChatTiledViewScrollHarnessModel { + var rows: [ChatTiledViewScrollRequestTests.Row] + var isAtBottom = true + var unreadCount = 0 + var scrollToBottomRequest = 0 + + init(rows: [ChatTiledViewScrollRequestTests.Row]) { + self.rows = rows + } +} + +/// Hosted regressions for `ChatTiledView` token, send, and unread policy. +@Suite("ChatTiledView scroll-to-bottom request", .serialized) +@MainActor +struct ChatTiledViewScrollRequestTests { + struct Row: Identifiable, Hashable { + let id: UUID + let index: Int + var countsTowardUnread: Bool = true + } + + private struct RowCell: View { + let item: Row + var body: some View { + Color.blue.frame(height: Harness.rowHeight) + } + } + + private struct Harness: View { + @Bindable var model: ChatTiledViewScrollHarnessModel + + var body: some View { + ChatTiledView( + items: model.rows, + cellContent: { RowCell(item: $0) }, + isAtBottom: $model.isAtBottom, + unreadCount: $model.unreadCount, + scrollToBottomRequest: model.scrollToBottomRequest, + countsTowardUnread: { $0.countsTowardUnread } + ) + .ignoresSafeArea() + } + + static let rowHeight: CGFloat = 44 + } + + private static let viewportHeight: CGFloat = 600 + private static let scrollAwayPoints: CGFloat = 400 + private static let collectionWaitTimeout: TimeInterval = 5 + private static let runLoopSlice: TimeInterval = 0.05 + private static let afterRequestSettle: TimeInterval = 0.3 + private static let stayPutSlop: CGFloat = 1 + + private func makeRows(count: Int) -> [Row] { + (0.. (UIWindow, UIHostingController, UICollectionView) { + let controller = UIHostingController(rootView: Harness(model: model)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: Self.viewportHeight)) + window.rootViewController = controller + window.isHidden = false + window.layoutIfNeeded() + let found = try #require(waitForCollectionView(in: window, itemCount: model.rows.count)) + return (window, controller, found.collectionView) + } + + private func waitForCollectionView( + in window: UIWindow, + itemCount: Int, + timeout: TimeInterval = collectionWaitTimeout + ) -> (collectionView: UICollectionView, messagesSection: Int)? { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopSlice)) + guard let collectionView = findCollectionView(in: window) else { continue } + for section in 0.. 0 { + return (collectionView, section) + } + } + } + return nil + } + + private func findCollectionView(in view: UIView) -> UICollectionView? { + if let collectionView = view as? UICollectionView { return collectionView } + for subview in view.subviews { + if let found = findCollectionView(in: subview) { return found } + } + return nil + } + + private func waitUntilNotAtBottom( + _ isAtBottom: @escaping () -> Bool, + timeout: TimeInterval = collectionWaitTimeout + ) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopSlice)) + if !isAtBottom() { return true } + } + return false + } + + private func waitUntilLastRowPinned( + _ found: (collectionView: UICollectionView, messagesSection: Int), + in rows: [Row], + timeout: TimeInterval = collectionWaitTimeout + ) -> CGFloat? { + let deadline = Date(timeIntervalSinceNow: timeout) + var lastBottom: CGFloat? + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopSlice)) + guard let screenBottom = try? lastRowScreenBottom(found, in: rows) else { continue } + lastBottom = screenBottom + if abs(screenBottom - found.collectionView.bounds.height) < Self.stayPutSlop { + return screenBottom + } + } + return lastBottom + } + + private func lastRowScreenBottom( + _ found: (collectionView: UICollectionView, messagesSection: Int), + id: UUID? = nil, + in rows: [Row] + ) throws -> CGFloat { + let itemIndex: Int = if let id { + try #require(rows.firstIndex(where: { $0.id == id })) + } else { + found.collectionView.numberOfItems(inSection: found.messagesSection) - 1 + } + let attributes = try #require(found.collectionView.layoutAttributesForItem( + at: IndexPath(item: itemIndex, section: found.messagesSection) + )) + return attributes.frame.maxY - found.collectionView.contentOffset.y + } + + private func scrollAway(_ collectionView: UICollectionView) { + collectionView.setContentOffset( + CGPoint(x: 0, y: collectionView.contentOffset.y - Self.scrollAwayPoints), + animated: false + ) + } + + private func findScrollToBottomControl(in view: UIView) -> UIControl? { + let label = L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityLabel + if let control = view as? UIControl, view.accessibilityLabel == label { + return control + } + for subview in view.subviews { + if let found = findScrollToBottomControl(in: subview) { return found } + } + return nil + } + + private func findLabeledView(in view: UIView, label: String) -> UIView? { + if view.accessibilityLabel == label { return view } + for subview in view.subviews { + if let found = findLabeledView(in: subview, label: label) { return found } + } + return nil + } + + private func findAccessibleElement(in view: UIView, label: String) -> NSObject? { + if view.accessibilityLabel == label { return view } + if let elements = view.accessibilityElements { + for case let object as NSObject in elements where object.accessibilityLabel == label { + return object + } + } + let count = view.accessibilityElementCount() + if count != NSNotFound { + for index in 0..= 1) + #expect(!model.isAtBottom) + } + + @Test + func `button tap then send still pins the new last row`() throws { + let model = ChatTiledViewScrollHarnessModel(rows: makeRows(count: 60)) + let (window, _, _) = try mount(model: model) + defer { window.isHidden = true } + + let found = try #require(waitForCollectionView(in: window, itemCount: model.rows.count)) + scrollAway(found.collectionView) + try #require(waitUntilNotAtBottom { model.isAtBottom }) + + try tapScrollToBottomButton(in: window) + model.rows.append(Row(id: UUID(), index: model.rows.count)) + window.layoutIfNeeded() + let afterAppend = try #require(waitForCollectionView(in: window, itemCount: model.rows.count)) + let screenBottom = try #require(waitUntilLastRowPinned(afterAppend, in: model.rows)) + + #expect( + abs(screenBottom - afterAppend.collectionView.bounds.height) < Self.stayPutSlop, + "button tap plus append must pin the new last row (bottom \(screenBottom), viewport \(afterAppend.collectionView.bounds.height))" + ) + } +} diff --git a/MC1Tests/Views/Chats/Components/IncomingAvatarFlightTests.swift b/MC1Tests/Views/Chats/Components/IncomingAvatarFlightTests.swift new file mode 100644 index 000000000..08cf76c6b --- /dev/null +++ b/MC1Tests/Views/Chats/Components/IncomingAvatarFlightTests.swift @@ -0,0 +1,565 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftUI +import Testing +import UIKit + +@Suite("Incoming avatar flight") +@MainActor +struct IncomingAvatarFlightTests { + @Test + func `same-sender admit starts a flight before the next run loop`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + let fromFrame = CGRect(x: 12, y: 500, width: 28, height: 28) + + viewModel.appendMessageIfNew(first) + flight.reportFrame(fromFrame, for: first.id) + viewModel.appendMessageIfNew(second) + + #expect(flight.flight?.fromID == first.id) + #expect(flight.flight?.toID == second.id) + #expect(flight.isFlying(second.id)) + #expect(flight.drawnFrame(progress: 0)?.origin == fromFrame.origin) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `beginFlight uses the continued cluster-end, not an older cluster`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let b = UUID() + let c = UUID() + let d = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.reportFrame(CGRect(x: 0, y: 400, width: 28, height: 28), for: c) + + flight.beginFlight(from: c, to: d, identity: .initials(name: "Alice")) + + #expect(flight.flight?.fromID == c) + #expect(flight.flight?.toID == d) + #expect(flight.isFlying(a) == false) + #expect(flight.isFlying(b) == false) + } + + @Test + func `scrolled-up admit does not start a flight`() { + let flight = IncomingAvatarFlight() + flight.isAtBottom = false + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: first.id) + viewModel.appendMessageIfNew(second) + + #expect(flight.flight == nil) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `missing from-frame does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + + #expect(flight.flight == nil) + } + + @Test + func `rebake that keeps the tail id does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + flight.complete() + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: second.id) + + viewModel.buildItems() + + #expect(flight.flight == nil) + #expect(viewModel.items.last?.id == second.id) + } + + @Test + func `status preview and photo patches do not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: first.id) + + viewModel.timeline.applyStatusUpdate(messageID: first.id, status: .delivered) + #expect(flight.flight == nil) + + viewModel.timeline.writer?.updateRenderItem(id: first.id) { item in + item.with(envelope: item.envelope.with(incomingAvatar: .initials(name: "Alice"))) + } + #expect(flight.flight == nil) + + viewModel.timeline.writer?.updateRenderItem(id: first.id) { item in + item.with( + envelope: item.envelope.with( + incomingAvatar: IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + ) + ) + } + #expect(flight.flight == nil) + } + + @Test + func `removing a cluster-end does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 400, width: 28, height: 28), for: first.id) + viewModel.appendMessageIfNew(second) + flight.complete() + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: second.id) + + viewModel.timeline.removeMessage(second.id) + + #expect(flight.flight == nil) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + } + + @Test + func `unbind drops the stored frame so a later hop cannot use it`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let c = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.unbind(a) + + #expect(flight.frame(for: a) == nil) + flight.beginFlight(from: a, to: c, identity: .initials(name: "Alice")) + #expect(flight.flight == nil) + } + + @Test + func `identity strip without a flight unbinds the stored frame`() { + let flight = IncomingAvatarFlight() + let messageID = UUID() + let holder = GutterIdentityHolder(identity: .initials(name: "Alice")) + let host = UIHostingController( + rootView: IdentityStripHost(holder: holder, messageID: messageID) + .environment(\.incomingAvatarFlight, flight) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: messageID) + + holder.identity = nil + window.layoutIfNeeded() + + #expect(flight.frame(for: messageID) == nil) + } + + @Test + func `identity strip during flight keeps the from-frame`() { + let flight = IncomingAvatarFlight() + let from = UUID() + let to = UUID() + let holder = GutterIdentityHolder(identity: .initials(name: "Alice")) + let host = UIHostingController( + rootView: IdentityStripHost(holder: holder, messageID: from) + .environment(\.incomingAvatarFlight, flight) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + let fromFrame = CGRect(x: 0, y: 100, width: 28, height: 28) + flight.reportFrame(fromFrame, for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + + holder.identity = nil + window.layoutIfNeeded() + + #expect(flight.isFlying(from)) + #expect(flight.frame(for: from) != nil) + } + + @Test + func `rebind reports the new id and clears the old`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let b = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.unbind(a) + flight.reportFrame(CGRect(x: 0, y: 200, width: 28, height: 28), for: b) + + #expect(flight.frame(for: a) == nil) + #expect(flight.frame(for: b)?.origin.y == 200) + } + + @Test + func `at rest only cluster-ends report; during flight only from and to`() { + let flight = IncomingAvatarFlight() + let ids = (0..<12).map { _ in UUID() } + let from = ids[10] + let to = ids[11] + + for id in ids { + #expect(flight.shouldReportFrame(messageID: id, hasIdentity: id == from) == (id == from)) + } + + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + + for id in ids { + let expected = id == from || id == to + #expect(flight.shouldReportFrame(messageID: id, hasIdentity: id == to) == expected) + } + + flight.complete() + for id in ids { + #expect(flight.shouldReportFrame(messageID: id, hasIdentity: id == to) == (id == to)) + } + } + + @Test + func `reportFrame does not invalidate a gutter that only reads the flying flag`() { + let flight = IncomingAvatarFlight() + let probeID = UUID() + let counter = BodyCounter() + let host = UIHostingController( + rootView: FlyingFlagProbe(messageID: probeID, counter: counter) + .environment(\.incomingAvatarFlight, flight) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 200)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + let afterAppear = counter.count + + let noise = UUID() + for index in 0..<30 { + flight.reportFrame( + CGRect(x: 0, y: CGFloat(index), width: 28, height: 28), + for: noise + ) + } + window.layoutIfNeeded() + + #expect(counter.count == afterAppear) + } + + @Test + func `overlapping beginFlight cancels instead of splicing`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let b = UUID() + let c = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.beginFlight(from: a, to: b, identity: .initials(name: "Alice")) + flight.reportFrame(CGRect(x: 0, y: 180, width: 28, height: 28), for: b) + flight.beginFlight(from: b, to: c, identity: .initials(name: "Alice")) + + #expect(flight.flight == nil) + #expect(flight.isFlying(a) == false) + #expect(flight.isFlying(b) == false) + } + + @Test + func `drawn frame lerps live frames not a captured snapshot`() { + let flight = IncomingAvatarFlight() + let from = UUID() + let to = UUID() + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + flight.reportFrame(CGRect(x: 0, y: 600, width: 28, height: 28), for: to) + + #expect(flight.drawnFrame(progress: 0.5)?.origin.y == 550) + + flight.reportFrame(CGRect(x: 0, y: 400, width: 28, height: 28), for: from) + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: to) + + #expect(flight.drawnFrame(progress: 0.5)?.origin.y == 450) + } + + @Test + func `reserved empty gutter keeps padding-only height`() { + let messageID = UUID() + let gutterHeight = measureHeight( + IncomingAvatarGutter(identity: nil, reserveColumn: true, messageID: messageID) { + Text("Hi") + } + ) + let paddedHeight = measureHeight( + Text("Hi").padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) + ) + #expect(gutterHeight == paddedHeight) + } + + @Test + func `rooms fly the matching prefix cluster only`() { + let flight = IncomingAvatarFlight() + let viewModel = RoomConversationViewModel() + viewModel.incomingAvatarFlight = flight + let aa = Data([0xAA]) + let bb = Data([0xBB]) + let firstBB = roomMessage(ts: 100, prefix: bb, name: "Alice") + let firstAA = roomMessage(ts: 160, prefix: aa, name: "Alice") + let followAA = roomMessage(ts: 220, prefix: aa, name: "Alice") + + viewModel.appendMessageIfNew(firstBB) + viewModel.appendMessageIfNew(firstAA) + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: firstBB.id) + flight.reportFrame(CGRect(x: 0, y: 200, width: 28, height: 28), for: firstAA.id) + viewModel.appendMessageIfNew(followAA) + + #expect(flight.flight?.fromID == firstAA.id) + #expect(flight.flight?.toID == followAA.id) + #expect(flight.isFlying(firstBB.id) == false) + } + + @Test + func `coalesced room loadMessages replace does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = RoomConversationViewModel() + viewModel.incomingAvatarFlight = flight + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let second = roomMessage(ts: 160, prefix: prefix, name: "Alice") + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: first.id) + viewModel.appendMessageIfNew(second) + flight.complete() + flight.reportFrame(CGRect(x: 0, y: 200, width: 28, height: 28), for: second.id) + + viewModel.messages = [first, second] + + #expect(flight.flight == nil) + } + + @Test + func `overlay hides the flying avatar from accessibility`() { + let flight = IncomingAvatarFlight() + let from = UUID() + let to = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + + let overlayHost = UIHostingController(rootView: flight.overlay().frame(width: 320, height: 400)) + let visibleHost = UIHostingController( + rootView: ContactAvatar(name: "Alice", size: IncomingBubbleAvatarMetrics.size) + .frame(width: 320, height: 400) + ) + let overlayWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 400)) + overlayWindow.rootViewController = overlayHost + overlayWindow.isHidden = false + overlayWindow.layoutIfNeeded() + let visibleWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 400)) + visibleWindow.rootViewController = visibleHost + visibleWindow.isHidden = false + visibleWindow.layoutIfNeeded() + + let overlayLabels = accessibilityLabels(in: overlayHost.view) + let visibleLabels = accessibilityLabels(in: visibleHost.view) + #expect(visibleLabels.contains { $0 == "A" || $0.localizedCaseInsensitiveContains("Alice") }) + #expect(overlayLabels.contains { $0 == "A" || $0.localizedCaseInsensitiveContains("Alice") } == false) + } + + @Test + func `cell wrap injects the flight environment; nil default still builds`() { + let flight = IncomingAvatarFlight() + let box = FlightBox() + let wrapped = EnvironmentProbe(box: box) + .environment(\.incomingAvatarFlight, flight) + let host = UIHostingController(rootView: wrapped) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + #expect(box.value != nil) + + let gutter = IncomingAvatarGutter( + identity: .initials(name: "Alice"), + reserveColumn: true, + messageID: UUID() + ) { + Text("Hi") + } + let nilHost = UIHostingController(rootView: gutter) + let nilWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + nilWindow.rootViewController = nilHost + nilWindow.isHidden = false + nilWindow.layoutIfNeeded() + #expect(nilHost.view != nil) + } + + @Test + func `reduce motion skips beginFlight`() { + let flight = IncomingAvatarFlight() + flight.reduceMotion = true + let from = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: UUID(), identity: .initials(name: "Alice")) + #expect(flight.flight == nil) + } +} + +@MainActor +private func boundViewModel(flight: IncomingAvatarFlight) -> ChatViewModel { + let viewModel = ChatViewModel() + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + viewModel.incomingAvatarFlight = flight + return viewModel +} + +private func createChannelMessage( + timestamp: UInt32, + senderName: String? = nil +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, + channelIndex: 0, + text: "Test message", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 roomMessage( + ts: UInt32, + prefix: Data, + name: String +) -> RoomMessageDTO { + RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: prefix, + authorName: name, + text: "msg", + timestamp: ts + ) +} + +@MainActor +private func measureHeight(_ view: some View) -> CGFloat { + let host = UIHostingController(rootView: view) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 800)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + return host.sizeThatFits(in: CGSize(width: 390, height: 800)).height +} + +@MainActor +private func accessibilityLabels(in view: UIView) -> [String] { + var labels: [String] = [] + if view.isAccessibilityElement, let label = view.accessibilityLabel, !label.isEmpty { + labels.append(label) + } + if let elements = view.accessibilityElements { + for element in elements { + guard let object = element as? NSObject, + let label = object.accessibilityLabel, + !label.isEmpty else { continue } + labels.append(label) + } + } + for subview in view.subviews { + labels.append(contentsOf: accessibilityLabels(in: subview)) + } + return labels +} + +private final class BodyCounter { + var count = 0 +} + +private struct FlyingFlagProbe: View { + let messageID: UUID + let counter: BodyCounter + @Environment(\.incomingAvatarFlight) private var flight + + var body: some View { + Text(flight?.isFlying(messageID) == true ? "flying" : "rest") + .background(BodyTick(counter: counter)) + } +} + +private struct BodyTick: View { + let counter: BodyCounter + + var body: Color { + counter.count += 1 + return Color.clear + } +} + +private final class FlightBox { + var value: IncomingAvatarFlight? +} + +@Observable +@MainActor +private final class GutterIdentityHolder { + var identity: IncomingAvatarIdentity? + + init(identity: IncomingAvatarIdentity?) { + self.identity = identity + } +} + +private struct IdentityStripHost: View { + let holder: GutterIdentityHolder + let messageID: UUID + + var body: some View { + IncomingAvatarGutter( + identity: holder.identity, + reserveColumn: true, + messageID: messageID + ) { + Text("Hi") + } + } +} + +private struct EnvironmentProbe: View { + let box: FlightBox + @Environment(\.incomingAvatarFlight) private var flight + + var body: some View { + Color.clear + .onAppear { box.value = flight } + } +} diff --git a/MC1Tests/Views/Chats/Components/IncomingAvatarJPEGStoreTests.swift b/MC1Tests/Views/Chats/Components/IncomingAvatarJPEGStoreTests.swift new file mode 100644 index 000000000..9e2bbdd22 --- /dev/null +++ b/MC1Tests/Views/Chats/Components/IncomingAvatarJPEGStoreTests.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("IncomingAvatarJPEGStore", .isolatedIncomingAvatarJPEGStore) +@MainActor +struct IncomingAvatarJPEGStoreTests { + @Test + func `replace stores JPEG only when matchedContactID owns the bytes`() { + let contact = makeContact(name: "Maya", jpeg: Data("unique-jpeg".utf8)) + replace(contact) + + #expect(IncomingAvatarJPEGStore.data(for: contact.id) == contact.avatarImageData) + #expect(IncomingAvatarJPEGStore.data(for: UUID()) == nil) + } + + @Test + func `nested isolated storage does not clobber the outer session`() { + let outer = makeContact(name: "Maya", jpeg: Data("outer-jpeg".utf8)) + let inner = makeContact(name: "Rico", jpeg: Data("inner-jpeg".utf8)) + + IncomingAvatarJPEGStore.$isolatedStorage.withValue(IncomingAvatarJPEGStore.Storage()) { + replace(outer) + #expect(IncomingAvatarJPEGStore.data(for: outer.id) == outer.avatarImageData) + + IncomingAvatarJPEGStore.$isolatedStorage.withValue(IncomingAvatarJPEGStore.Storage()) { + replace(inner) + #expect(IncomingAvatarJPEGStore.data(for: inner.id) == inner.avatarImageData) + #expect(IncomingAvatarJPEGStore.data(for: outer.id) == nil) + } + + #expect(IncomingAvatarJPEGStore.data(for: outer.id) == outer.avatarImageData) + #expect(IncomingAvatarJPEGStore.data(for: inner.id) == nil) + } + } + + private func replace(_ contact: ContactDTO) { + let identities = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact]) + IncomingAvatarJPEGStore.replace(contacts: [contact], identities: Array(identities.values)) + } + + private func makeContact(name: String, jpeg: Data) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0xAA, count: ProtocolLimits.publicKeySize), + name: name, + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + avatarImageData: jpeg + ) + } +} diff --git a/MC1Tests/Views/Chats/Components/IsolatedIncomingAvatarJPEGStoreTrait.swift b/MC1Tests/Views/Chats/Components/IsolatedIncomingAvatarJPEGStoreTrait.swift new file mode 100644 index 000000000..a24b8f5d7 --- /dev/null +++ b/MC1Tests/Views/Chats/Components/IsolatedIncomingAvatarJPEGStoreTrait.swift @@ -0,0 +1,27 @@ +@testable import MC1 +import Testing + +/// Binds a fresh `IncomingAvatarJPEGStore` box for each test so replace-all +/// cannot wipe another suite's map across an `await`. +struct IsolatedIncomingAvatarJPEGStoreTrait: SuiteTrait, TestTrait, TestScoping { + var isRecursive: Bool { + true + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + let storage = await MainActor.run { IncomingAvatarJPEGStore.Storage() } + try await IncomingAvatarJPEGStore.$isolatedStorage.withValue(storage) { + try await function() + } + } +} + +extension SuiteTrait where Self == IsolatedIncomingAvatarJPEGStoreTrait { + static var isolatedIncomingAvatarJPEGStore: Self { + Self() + } +} diff --git a/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift b/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift index 6e85eeb14..fc05c2f5d 100644 --- a/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift +++ b/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift @@ -172,6 +172,68 @@ struct MessageBubblePredicateTests { #expect(bubble.accessibilityMessageLabel.contains(regionFragment) == true) } + @Test + func `footer bakes multi-match chip label and ambiguous flag`() { + let message = makeMessage( + routeType: .flood, + regionScope: "Germany", // sticky first-match must lose to multi-match + regionScopeMatches: ["de-hh", "de-by"] + ) + let bundle = MessageBubbleTestData.messageItem( + message: message, + showIncomingRegion: true + ) + let footer = bundle.item.footer + + #expect(footer.regionToShow == "de-by / de-hh") + #expect(footer.regionIsAmbiguous) + #expect(footer.regionMatchNames == ["de-by", "de-hh"]) + } + + @Test + func `footer bakes legacy scope-only as unique without ambiguous flag`() { + let message = makeMessage(routeType: .flood, regionScope: "Germany", regionScopeMatches: []) + let bundle = MessageBubbleTestData.messageItem( + message: message, + showIncomingRegion: true + ) + let footer = bundle.item.footer + + #expect(footer.regionToShow == "Germany") + #expect(!footer.regionIsAmbiguous) + #expect(footer.regionMatchNames == ["Germany"]) + } + + @Test + func `accessibilityMessageLabel lists all candidates when ambiguous`() { + // Ambiguous region footer (nil sticky scope, multi-match candidates) must surface + // every candidate name in the screen-reader label, not only the chip text. + let message = makeMessage( + routeType: .flood, + regionScope: nil, + regionScopeMatches: ["de-hh", "de-by"] + ) + let bundle = MessageBubbleTestData.messageItem( + message: message, + showIncomingRegion: true + ) + let footer = bundle.item.footer + let bubble = UnifiedMessageBubble( + message: message, + contactName: "Alice", + configuration: .directMessage, + item: bundle.item, + layout: FragmentLayout(content: bundle.item.content), + imageResolver: bundle.imageResolver + ) + + #expect(footer.regionIsAmbiguous) + #expect(footer.regionMatchNames.contains("de-by")) + #expect(footer.regionMatchNames.contains("de-hh")) + #expect(bubble.accessibilityMessageLabel.contains("de-by")) + #expect(bubble.accessibilityMessageLabel.contains("de-hh")) + } + // MARK: - Helpers private func makeMessage( @@ -180,7 +242,8 @@ struct MessageBubblePredicateTests { pathNodes: Data? = Data([0xA3, 0x7F]), direction: MessageDirection = .incoming, routeType: RouteType? = nil, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) -> MessageDTO { MessageDTO( id: UUID(), @@ -206,7 +269,8 @@ struct MessageBubblePredicateTests { retryAttempt: 0, maxRetryAttempts: 0, routeType: routeType, - regionScope: regionScope + regionScope: regionScope, + regionScopeMatches: regionScopeMatches ) } } diff --git a/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift b/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift index edd6435ed..8deca9bf2 100644 --- a/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift +++ b/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift @@ -167,7 +167,8 @@ enum MessageBubbleTestData { isOffline: false, currentUserName: currentUserName, themeID: "default", - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) let item = MessageFragmentBuilder.makeItem(for: message, inputs: inputs, envInputs: envInputs) diff --git a/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift b/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift index de73f2854..df146ebbc 100644 --- a/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift +++ b/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift @@ -22,7 +22,8 @@ struct MessageStatusTextTests { date: Date(timeIntervalSince1970: 1_700_000_000), hasFailed: status == .failed, containsSelfMention: false, - mentionSeen: false + mentionSeen: false, + incomingAvatar: nil ), content: [], footer: MessageFooter( diff --git a/MC1Tests/Views/Chats/Components/RepeatRowViewTests.swift b/MC1Tests/Views/Chats/Components/RepeatRowViewTests.swift new file mode 100644 index 000000000..73fa35ddc --- /dev/null +++ b/MC1Tests/Views/Chats/Components/RepeatRowViewTests.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("RepeatRowView") +struct RepeatRowViewTests { + @Test + func `unique short prefix is exact`() { + let result = RepeatRowView.resolution( + repeaterHash: Data([0x3F]), + repeaters: [createRepeater(prefix: 0x3F, secondByte: 0x01, name: "Ridge")], + discoveredRepeaters: [], + userLocation: nil + ) + + #expect(result.displayName == "Ridge") + #expect(result.matchKind == .exact) + } + + @Test + func `colliding short prefix is fallback`() { + let result = RepeatRowView.resolution( + repeaterHash: Data([0x3F]), + repeaters: [ + createRepeater(prefix: 0x3F, secondByte: 0x01, name: "Ridge"), + createRepeater(prefix: 0x3F, secondByte: 0x02, name: "Valley") + ], + discoveredRepeaters: [], + userLocation: nil + ) + + #expect(result.matchKind == .fallback) + } + + @Test + func `unmatched hash is unresolved`() { + let result = RepeatRowView.resolution( + repeaterHash: Data([0xAA]), + repeaters: [createRepeater(prefix: 0x3F, secondByte: 0x01, name: "Ridge")], + discoveredRepeaters: [], + userLocation: nil + ) + + #expect(result.matchKind == .unresolved) + } + + private func createRepeater(prefix: UInt8, secondByte: UInt8, name: String) -> 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: 10, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } +} diff --git a/MC1Tests/Views/Chats/Components/RoomMessageBubbleA11yLabelTests.swift b/MC1Tests/Views/Chats/Components/RoomMessageBubbleA11yLabelTests.swift new file mode 100644 index 000000000..bfbd48f75 --- /dev/null +++ b/MC1Tests/Views/Chats/Components/RoomMessageBubbleA11yLabelTests.swift @@ -0,0 +1,147 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@MainActor +@Suite("RoomMessageBubble accessibility label") +struct RoomMessageBubbleA11yLabelTests { + @Test + func `incoming follow-up label still includes the author`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: "follow up", + timestamp: 100 + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: true + ) + + #expect(bubble.accessibilityMessageLabel == "Alice: follow up") + } + + @Test + func `incoming cluster-start announces the author once`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: "hello", + timestamp: 100 + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: true, + showAvatar: true + ) + + #expect(bubble.accessibilityMessageLabel == "Alice: hello") + #expect(bubble.accessibilityMessageLabel.components(separatedBy: "Alice").count == 2) + } + + @Test + func `outgoing label includes status once`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x42]), + authorName: "Me", + text: "hello", + timestamp: 100, + isFromSelf: true, + status: .sent + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: false + ) + #expect(bubble.accessibilityMessageLabel == "hello, \(message.accessibilityStatusLabel)") + let statusCount = bubble.accessibilityMessageLabel.components(separatedBy: message.accessibilityStatusLabel).count + #expect(statusCount == 2) + } + + @Test + func `failed outgoing exposes a retry accessibility action`() { + var retried = false + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x42]), + authorName: "Me", + text: "hello", + timestamp: 100, + isFromSelf: true, + status: .failed + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: false, + onRetry: { retried = true } + ) + #expect(bubble.accessibilityShowsRetryAction) + bubble.performAccessibilityRetry() + #expect(retried) + } + + @Test + func `sent outgoing does not expose a retry accessibility action`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x42]), + authorName: "Me", + text: "hello", + timestamp: 100, + isFromSelf: true, + status: .sent + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: false, + onRetry: {} + ) + #expect(!bubble.accessibilityShowsRetryAction) + } + + @Test + func `https body text yields a named open-link action`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: "see https://example.com", + timestamp: 100 + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: true, + showAvatar: true + ) + let formatted = MessageText.buildFormattedText( + text: message.text, + isOutgoing: false, + currentUserName: nil, + isHighContrast: false, + outgoingTextColor: .white, + hashtagColor: .blue, + identityGamut: IdentityGamut( + hueAnchors: [18, 25, 44, 77, 120, 180, 215, 255, 307, 343], + saturation: 0.45...0.70 + ), + identityBackgroundLuminances: [0.2, 0.8] + ).text + let actions = bubble.accessibilityLinkActions(formatted: formatted) + #expect(actions.contains { $0.url.host() == "example.com" }) + #expect(actions.contains { $0.name == L10n.Chats.Chats.Message.Action.openWebLink("example.com") }) + } +} diff --git a/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift b/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift index b01d4368e..e6bc0b5f9 100644 --- a/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift +++ b/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift @@ -14,7 +14,8 @@ struct UnifiedMessageBubbleA11yLabelTests { regionScope: "NORTHWEST" ) let configuration = MessageBubbleConfiguration( - showSenderName: true + showSenderName: true, + showsIncomingAvatars: false ) let bundle = MessageBubbleTestData.messageItem( message: message, @@ -61,7 +62,8 @@ struct UnifiedMessageBubbleA11yLabelTests { func `fallback sender label includes possible-match disclosure`() { let message = MessageBubbleTestData.incomingChannel(text: "hi", senderNodeName: nil) let configuration = MessageBubbleConfiguration( - showSenderName: true + showSenderName: true, + showsIncomingAvatars: false ) let bundle = MessageBubbleTestData.messageItem( message: message, diff --git a/MC1Tests/Views/Chats/ContactMatchRowTests.swift b/MC1Tests/Views/Chats/ContactMatchRowTests.swift new file mode 100644 index 000000000..643c8839b --- /dev/null +++ b/MC1Tests/Views/Chats/ContactMatchRowTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("Contact recency semantics") +struct ContactMatchRowTests { + /// `recencyTimestamp` follows `lastModified`, which a path update or a + /// favorite toggle bumps with no on-air advert. Anything that must mean + /// "when did we last hear this node" reads `lastAdvertTimestamp` instead. + @Test + func `recencyTimestamp tracks lastModified not lastAdvertTimestamp`() { + let lastAdvert: UInt32 = 1000 + let lastModified: UInt32 = 10000 + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0xAB, count: 32), + name: "Relay", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: lastAdvert, + latitude: 0, + longitude: 0, + lastModified: lastModified, + lastHeardTimestamp: lastAdvert, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + + #expect(contact.recencyTimestamp == lastModified) + #expect(contact.recencyTimestamp != contact.lastAdvertTimestamp) + } +} diff --git a/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift b/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift index 9457d57bb..0d0b1ef27 100644 --- a/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift +++ b/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift @@ -31,6 +31,7 @@ struct ConversationListScrollPerfHarnessTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift b/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift index 4fe4f53cc..18b9aa285 100644 --- a/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift +++ b/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift @@ -5,7 +5,13 @@ import Testing @Suite("MessageBubbleConfiguration") struct MessageBubbleConfigurationTests { - private func createContact(prefix: [UInt8], name: String, lastAdvertTimestamp: UInt32, nickname: String? = nil) -> ContactDTO { + private func createContact( + prefix: [UInt8], + name: String, + lastAdvertTimestamp: UInt32, + nickname: String? = nil, + avatarImageData: Data? = nil + ) -> ContactDTO { ContactDTO( id: UUID(), radioID: UUID(), @@ -19,12 +25,14 @@ struct MessageBubbleConfigurationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, isFavorite: false, lastMessageDate: nil, - unreadCount: 0 + unreadCount: 0, + avatarImageData: avatarImageData ) } @@ -54,6 +62,12 @@ struct MessageBubbleConfigurationTests { ) } + @Test + func `directMessage factory disables incoming avatars`() { + #expect(MessageBubbleConfiguration.directMessage.showsIncomingAvatars == false) + #expect(MessageBubbleConfiguration.channel(isPublic: true).showsIncomingAvatars == true) + } + @Test func `channel sender resolver marks short prefix match as fallback`() { let older = createContact(prefix: [0xAA, 0x01], name: "Older", lastAdvertTimestamp: 100) @@ -138,4 +152,58 @@ struct MessageBubbleConfigurationTests { #expect(result.unverifiedNickname == nil) } + + @Test + func `incomingAvatarIdentities maps a unique name without a nickname`() { + let contact = createContact(prefix: [0xAA, 0x01], name: "Alpha", lastAdvertTimestamp: 100) + + let lookup = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact]) + let identity = lookup["alpha"] + + #expect(identity?.matchedContactID == contact.id) + #expect(identity?.name == "Alpha") + #expect(identity?.imageRevision == nil) + } + + @Test + func `local avatar replace changes imageRevision when lastModified is unchanged`() { + let firstBytes = Data(repeating: 0x01, count: 64) + let secondBytes = Data(repeating: 0x02, count: 64) + let contact = createContact( + prefix: [0xAA, 0x01], + name: "Alpha", + lastAdvertTimestamp: 100, + avatarImageData: firstBytes + ) + let replaced = contact.with(avatarImageData: secondBytes) + #expect(contact.lastModified == replaced.lastModified) + + let first = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact])["alpha"] + let second = MessageBubbleConfiguration.incomingAvatarIdentities(from: [replaced])["alpha"] + #expect(first?.imageRevision != second?.imageRevision) + } + + @Test + func `incomingAvatarIdentities keeps a unique name when another name collides`() { + let alice = createContact(prefix: [0xAA, 0x01], name: "Alice", lastAdvertTimestamp: 100) + let bob1 = createContact(prefix: [0xAA, 0x02], name: "Bob", lastAdvertTimestamp: 100) + let bob2 = createContact(prefix: [0xAA, 0x03], name: "bob", lastAdvertTimestamp: 200) + + let lookup = MessageBubbleConfiguration.incomingAvatarIdentities(from: [alice, bob1, bob2]) + #expect(lookup["alice"]?.matchedContactID == alice.id) + #expect(lookup["bob"] == nil) + } + + @Test + func `incomingAvatarIdentities keys on contact name not nickname`() { + let contact = createContact( + prefix: [0xAA, 0x01], + name: "Alpha", + lastAdvertTimestamp: 100, + nickname: "Rico" + ) + let lookup = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact]) + #expect(lookup["alpha"]?.matchedContactID == contact.id) + #expect(lookup["rico"] == nil) + } } diff --git a/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift b/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift index 06b14ec3a..96d417acc 100644 --- a/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift +++ b/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift @@ -230,7 +230,8 @@ private func makeFakeMessageItem( date: Date(timeIntervalSince1970: 1_700_000_000), hasFailed: false, containsSelfMention: false, - mentionSeen: false + mentionSeen: false, + incomingAvatar: nil ), content: [], footer: MessageFooter( diff --git a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift index 441b24ac9..4cc42ebfe 100644 --- a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift +++ b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift @@ -142,7 +142,8 @@ enum MessageFragmentBuilderFixtures { isOffline: false, currentUserName: isOutgoing ? "Me" : "Sender", themeID: "default", - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } } diff --git a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift index c4064bab9..f63066507 100644 --- a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift +++ b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift @@ -20,6 +20,54 @@ struct MessageFragmentBuilderTests { #expect(text.raw == "hello") } + @Test + func `translation chrome copies onto the payload and leaves raw original`() { + let original = "Guten Morgen, wie geht es dir heute?" + let message = makeMessage(text: original) + let offer = MessageFragmentBuilder.makeItem( + for: message, + inputs: makeInputs( + messageID: message.id, + translation: MessageTranslationChrome(phase: .offer, sourceLanguageCode: "de") + ), + envInputs: makeEnvInputs() + ) + let showing = MessageFragmentBuilder.makeItem( + for: message, + inputs: makeInputs( + messageID: message.id, + translation: MessageTranslationChrome( + phase: .showing(translatedText: "Hello", targetLanguageCode: "en"), + sourceLanguageCode: "de" + ) + ), + envInputs: makeEnvInputs() + ) + let none = MessageFragmentBuilder.makeItem( + for: message, + inputs: makeInputs(messageID: message.id, translation: nil), + envInputs: makeEnvInputs() + ) + guard case let .text(offerPayload) = offer.content[0], + case let .text(showingPayload) = showing.content[0], + case let .text(nonePayload) = none.content[0] else { + Issue.record("expected .text fragment") + return + } + #expect(offerPayload.raw == original) + #expect(offerPayload.translation?.phase == .offer) + #expect(showingPayload.raw == original) + #expect( + showingPayload.translation?.phase + == .showing(translatedText: "Hello", targetLanguageCode: "en") + ) + #expect(nonePayload.raw == original) + #expect(nonePayload.translation == nil) + #expect(offer != showing) + #expect(offer != none) + #expect(showing != none) + } + @Test func `reaction summary appears after the text fragment`() { let message = makeMessage(text: "hi", reactionSummary: "👍:1") @@ -669,7 +717,8 @@ struct MessageFragmentBuilderTests { isOffline: isOffline, currentUserName: currentUserName, themeID: "default", - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } @@ -726,7 +775,8 @@ struct MessageFragmentBuilderTests { showTimestamp: Bool = false, showDirectionGap: Bool = false, showSenderName: Bool = false, - showNewMessagesDivider: Bool = false + showNewMessagesDivider: Bool = false, + translation: MessageTranslationChrome? = nil ) -> MessageBuildInputs { MessageBuildInputs( messageID: messageID, @@ -745,7 +795,8 @@ struct MessageFragmentBuilderTests { showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, - showNewMessagesDivider: showNewMessagesDivider + showNewMessagesDivider: showNewMessagesDivider, + translation: translation ) } diff --git a/MC1Tests/Views/Chats/SenderContactMatcherTests.swift b/MC1Tests/Views/Chats/SenderContactMatcherTests.swift index e69fd4a06..abaf4ef13 100644 --- a/MC1Tests/Views/Chats/SenderContactMatcherTests.swift +++ b/MC1Tests/Views/Chats/SenderContactMatcherTests.swift @@ -84,6 +84,7 @@ struct SenderContactMatcherTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/Views/Components/AvatarCropGeometryTests.swift b/MC1Tests/Views/Components/AvatarCropGeometryTests.swift new file mode 100644 index 000000000..db81e7459 --- /dev/null +++ b/MC1Tests/Views/Components/AvatarCropGeometryTests.swift @@ -0,0 +1,88 @@ +import Foundation +@testable import MC1 +import Testing + +@Suite("AvatarCropGeometry Tests") +struct AvatarCropGeometryTests { + @Test + func `fill size covers the crop square for a landscape image`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 200, height: 100)) + #expect(geometry.baseDisplaySize == CGSize(width: 200, height: 100)) + } + + @Test + func `fill size covers the crop square for a portrait image`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 100, height: 200)) + #expect(geometry.baseDisplaySize == CGSize(width: 100, height: 200)) + } + + @Test + func `zero image size falls back to the crop square`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: .zero) + #expect(geometry.baseDisplaySize == CGSize(width: 100, height: 100)) + } + + @Test + func `scale clamps to 1...4`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 100, height: 100)) + #expect(geometry.clampedScale(0.5) == 1) + #expect(geometry.clampedScale(1) == 1) + #expect(geometry.clampedScale(2) == 2) + #expect(geometry.clampedScale(8) == 4) + } + + @Test + func `offset clamps so the crop square stays covered`() { + let geometry = AvatarCropGeometry( + cropSize: 100, + imageSize: CGSize(width: 100, height: 100), + scale: 2, + offset: .zero + ) + let clamped = geometry.clampedOffset(CGSize(width: 1000, height: -1000)) + #expect(clamped.width == 50) + #expect(clamped.height == -50) + } + + @Test + func `live pinch reclamps offset to the live scale`() { + let geometry = AvatarCropGeometry( + cropSize: 100, + imageSize: CGSize(width: 100, height: 100), + scale: 4, + offset: CGSize(width: 150, height: 0) + ) + let live = geometry.liveTransform(pinchDelta: 0.5, dragTranslation: .zero) + #expect(live.scale == 2) + #expect(live.offset.width == 50) + #expect(live.offset.height == 0) + } + + @Test + func `draw rect at scale 1 offset 0 matches the centered fill`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 200, height: 100)) + let rect = geometry.imageDrawRect(outputSide: 512) + #expect(abs(rect.origin.x - -256) < 0.001) + #expect(abs(rect.origin.y - 0) < 0.001) + #expect(abs(rect.width - 1024) < 0.001) + #expect(abs(rect.height - 512) < 0.001) + } + + @Test + func `crop size shrinks on a short landscape canvas`() { + let size = AvatarCropGeometry.cropSize( + in: CGSize(width: 667, height: 280), + isRegularWidth: false + ) + #expect(size == 280 - (AvatarCropGeometry.cropMargin * 2)) + } + + @Test + func `crop size uses the regular cap on iPad`() { + let size = AvatarCropGeometry.cropSize( + in: CGSize(width: 1024, height: 768), + isRegularWidth: true + ) + #expect(size == AvatarCropGeometry.regularMaxCropSize) + } +} diff --git a/MC1Tests/Views/Components/WiFiHostValidationTests.swift b/MC1Tests/Views/Components/WiFiHostValidationTests.swift new file mode 100644 index 000000000..6b10dadc3 --- /dev/null +++ b/MC1Tests/Views/Components/WiFiHostValidationTests.swift @@ -0,0 +1,21 @@ +import Foundation +@testable import MC1 +import Testing + +@Suite("WiFi host validation") +struct WiFiHostValidationTests { + @Test(arguments: [ + ("192.168.1.50", true), + ("radio.local", true), + ("example.com", true), + ("repeater", true), + (" radio.local ", true), + ("", false), + ("999.999.999.999", false), + ("192.168.1", false), + ("http://radio.local", false), + ]) + func `classifies host`(_ host: String, _ expected: Bool) { + #expect(WiFiAddressFields.isValidHost(host) == expected) + } +} diff --git a/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift b/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift index 1c6e99b7e..d1c01a3c8 100644 --- a/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift +++ b/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift @@ -31,6 +31,7 @@ struct ContactsViewModelDeleteTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift b/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift index ef332fc11..a7b0f1cdb 100644 --- a/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift +++ b/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift @@ -39,6 +39,7 @@ struct MapViewModelDiscoveredTests { latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Map/MapViewModelTests.swift b/MC1Tests/Views/Map/MapViewModelTests.swift index f848f284f..291915c14 100644 --- a/MC1Tests/Views/Map/MapViewModelTests.swift +++ b/MC1Tests/Views/Map/MapViewModelTests.swift @@ -22,6 +22,7 @@ struct MapViewModelFocusTests { latitude: 37.0, longitude: -122.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Map/TracePathMapFilterTests.swift b/MC1Tests/Views/Map/TracePathMapFilterTests.swift index 81f5bbd33..e5242e5e4 100644 --- a/MC1Tests/Views/Map/TracePathMapFilterTests.swift +++ b/MC1Tests/Views/Map/TracePathMapFilterTests.swift @@ -25,6 +25,7 @@ struct TracePathMapFilterTests { latitude: 37, longitude: -122, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -234,6 +235,7 @@ struct TracePathMapFilterTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -448,6 +450,7 @@ struct TracePathMapFilterTests { latitude: 50, longitude: -100, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift index 5278baa51..d76ef4247 100644 --- a/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift +++ b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift @@ -40,6 +40,7 @@ struct NeighborSNRMapBuilderTests { latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -71,6 +72,26 @@ struct NeighborSNRMapBuilderTests { NeighbourInfo(publicKeyPrefix: Data(prefix), secondsAgo: 0, snr: snr) } + private func build( + session: RemoteNodeSessionDTO, + neighbors: [NeighbourInfo], + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation?, + filter: MapFilterState, + keyDisplayByteCount: Int = NeighborNameResolver.minimumKeyDisplayByteCount + ) -> NeighborSNRMapBuilder.PlottedNeighbors { + NeighborSNRMapBuilder.build( + session: session, + neighbors: neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation, + filter: filter, + keyDisplayByteCount: keyDisplayByteCount + ) + } + private let exactPrefix: [UInt8] = [0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6] private let secondExactPrefix: [UInt8] = [0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6] @@ -99,7 +120,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -140,7 +161,7 @@ struct NeighborSNRMapBuilderTests { ] let neighbors = [makeNeighbor(prefix: exactPrefix), makeNeighbor(prefix: secondExactPrefix)] - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: neighbors, contacts: contacts, @@ -167,7 +188,7 @@ struct NeighborSNRMapBuilderTests { let contact = makeContact(prefix: exactPrefix, name: "Ridge", latitude: 37.1, longitude: -122.1) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -192,7 +213,7 @@ struct NeighborSNRMapBuilderTests { let node = makeDiscoveredNode(prefix: [0xAB, 0xEF], name: "Advert", latitude: 38.0, longitude: -123.0) let neighbor = makeNeighbor(prefix: [0xAB]) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -213,7 +234,7 @@ struct NeighborSNRMapBuilderTests { let contact = makeContact(prefix: exactPrefix, name: "No GPS", latitude: 0, longitude: 0) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -236,7 +257,7 @@ struct NeighborSNRMapBuilderTests { let node = makeDiscoveredNode(prefix: exactPrefix, name: "Bad GPS", latitude: 200.0, longitude: -122.1) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], @@ -254,20 +275,71 @@ struct NeighborSNRMapBuilderTests { func `unresolved neighbor falls back to a hex name and is listed`() { let session = makeSession(latitude: 37.0, longitude: -122.0) let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + let keyDisplayByteCount = NeighborNameResolver.minimumKeyDisplayByteCount - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], discoveredNodes: [], userLocation: nil, - filter: MapFilterState() + filter: MapFilterState(), + keyDisplayByteCount: keyDisplayByteCount ) #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)) + #expect( + result.unplottable.first?.displayName + == NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix, byteCount: keyDisplayByteCount) + ) + } + + @Test(arguments: [2, 3]) + func `unresolved title hex length matches secondary formatting for the same count`(keyDisplayByteCount: Int) { + let session = makeSession(latitude: 37.0, longitude: -122.0) + let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + let expected = NeighborNameResolver.fallbackName( + for: neighbor.publicKeyPrefix, + byteCount: keyDisplayByteCount + ) + + let result = build( + session: session, + neighbors: [neighbor], + contacts: [], + discoveredNodes: [], + userLocation: nil, + filter: MapFilterState(), + keyDisplayByteCount: keyDisplayByteCount + ) + + let unplottable = result.unplottable.first + #expect(unplottable?.matchKind == .unresolved) + #expect(unplottable?.displayName == expected) + #expect(unplottable?.displayName.count == keyDisplayByteCount * 2) + } + + @Test + func `colliding unresolved titles widen to the full prefix`() { + let session = makeSession(latitude: 37.0, longitude: -122.0) + let first = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + let second = makeNeighbor(prefix: [0xDE, 0xAD, 0x01, 0x02, 0x03, 0x04]) + let distinct = makeNeighbor(prefix: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + + let result = build( + session: session, + neighbors: [first, second, distinct], + contacts: [], + discoveredNodes: [], + userLocation: nil, + filter: MapFilterState(), + keyDisplayByteCount: NeighborNameResolver.minimumKeyDisplayByteCount + ) + + let titles = result.unplottable.map(\.displayName) + #expect(titles == ["DEADBEEF0001", "DEAD01020304", "AABB"]) } @Test @@ -278,7 +350,7 @@ struct NeighborSNRMapBuilderTests { makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x02]) ] - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: neighbors, contacts: [], @@ -299,7 +371,7 @@ struct NeighborSNRMapBuilderTests { let session = makeSession(latitude: 0, longitude: 0) let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], @@ -334,7 +406,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbors = [makeNeighbor(prefix: exactPrefix), makeNeighbor(prefix: secondExactPrefix)] - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: neighbors, contacts: [favorite, other], @@ -365,7 +437,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbor = makeNeighbor(prefix: exactPrefix) - let withD = NeighborSNRMapBuilder.build( + let withD = build( session: session, neighbors: [neighbor], contacts: [], @@ -375,7 +447,7 @@ struct NeighborSNRMapBuilderTests { ) #expect(withD.points.count(where: { $0.pinStyle == .repeater }) == 1) - let withoutD = NeighborSNRMapBuilder.build( + let withoutD = build( session: session, neighbors: [neighbor], contacts: [], @@ -394,7 +466,7 @@ struct NeighborSNRMapBuilderTests { let contact = makeContact(prefix: exactPrefix, name: "Ridge", latitude: 37.1, longitude: -122.1) let neighbor = makeNeighbor(prefix: exactPrefix) - let first = NeighborSNRMapBuilder.build( + let first = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -402,7 +474,7 @@ struct NeighborSNRMapBuilderTests { userLocation: nil, filter: MapFilterState() ) - let second = NeighborSNRMapBuilder.build( + let second = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -425,7 +497,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], diff --git a/MC1Tests/Views/RemoteNodes/NodeContactInfoSectionTests.swift b/MC1Tests/Views/RemoteNodes/NodeContactInfoSectionTests.swift new file mode 100644 index 000000000..d85dbbc49 --- /dev/null +++ b/MC1Tests/Views/RemoteNodes/NodeContactInfoSectionTests.swift @@ -0,0 +1,177 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftUI +import Testing +import UIKit + +/// Hosts the production Contact Info section and types through the real +/// UITextView so a binding or drawing failure matches the user's symptom: +/// typed characters missing while focused, and Apply sending a stale value. +@Suite("NodeContactInfoSection typing", .serialized) +@MainActor +struct NodeContactInfoSectionTests { + private static let existingInfo = "KD7ABC" + private static let typedSuffix = " extra" + private static let viewport = CGRect(x: 0, y: 0, width: 390, height: 844) + + private struct Harness: View { + @Bindable var settings: NodeSettingsViewModel + @FocusState var focusedField: NodeSettingsField? + + var body: some View { + NavigationStack { + Form { + NodeContactInfoSection(settings: settings, focusedField: $focusedField) + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil + } + } + } + } + } + } + + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + var reply = "OK" + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + return reply + } + } + + @Test + func `typing into contact info updates ownerInfo while focused`() async throws { + let settings = makeLoadedSettings() + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + textView.insertText(Self.typedSuffix) + window.layoutIfNeeded() + + try await waitUntil("ownerInfo never picked up typed text") { + settings.ownerInfo == Self.existingInfo + Self.typedSuffix + } + #expect( + textView.text.contains(Self.typedSuffix), + "typed characters must be visible in the field while focused; after Done is too late" + ) + } + + @Test + func `contact info text color stays readable while the field is focused`() async throws { + let settings = makeLoadedSettings() + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + let unfocusedColor = textView.textColor + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + try await Task.sleep(for: .milliseconds(50)) + + let focusedColor = textView.textColor + let alpha = focusedColor?.cgColor.alpha ?? -1 + #expect( + alpha > 0.2, + "focused text color is invisible (unfocused=\(String(describing: unfocusedColor)), focused=\(String(describing: focusedColor)), bg=\(String(describing: textView.backgroundColor)))" + ) + #expect( + textView.bounds.height > 8, + "focused field collapsed to \(textView.bounds.size), which would hide typed glyphs" + ) + } + + @Test + func `apply after typing sends set owner.info with the edited value`() async throws { + let recorder = CommandRecorder() + let settings = makeLoadedSettings(recorder: recorder) + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + textView.insertText(Self.typedSuffix) + _ = textView.resignFirstResponder() + window.layoutIfNeeded() + + try await waitUntil("ownerInfo never committed after resign") { + settings.ownerInfo == Self.existingInfo + Self.typedSuffix + } + + await settings.applyContactInfoSettings() + + #expect(recorder.commands == ["set owner.info \(Self.existingInfo + Self.typedSuffix)"]) + } + + @Test + func `backspace on loaded contact info updates ownerInfo`() async throws { + let settings = makeLoadedSettings() + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + textView.deleteBackward() + window.layoutIfNeeded() + + try await waitUntil("backspace never reached ownerInfo") { + settings.ownerInfo == String(Self.existingInfo.dropLast()) + } + } + + private func makeLoadedSettings(recorder: CommandRecorder = CommandRecorder()) -> NodeSettingsViewModel { + let settings = NodeSettingsViewModel() + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ) + settings.configure(session: session, sendCommand: recorder.send, sendRawCommand: recorder.send) + settings.setNodeInfo(firmwareVersion: "v1.17.1", name: "Test Repeater", ownerInfo: Self.existingInfo) + settings.isContactInfoExpanded = true + return settings + } + + private func mount( + settings: NodeSettingsViewModel + ) -> (UIWindow, UIHostingController) { + let controller = UIHostingController(rootView: Harness(settings: settings)) + let window = UIWindow(frame: Self.viewport) + window.rootViewController = controller + window.isHidden = false + window.layoutIfNeeded() + return (window, controller) + } + + private func waitForTextView(in window: UIWindow) async throws -> UITextView { + var found: UITextView? + try await waitUntil("contact info UITextView never appeared") { + found = findTextView(in: window) + return found != nil + } + return try #require(found) + } + + private func findTextView(in view: UIView) -> UITextView? { + if let textView = view as? UITextView { return textView } + for subview in view.subviews { + if let found = findTextView(in: subview) { return found } + } + return nil + } +} diff --git a/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift b/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift index c72f6cca8..59115ab7d 100644 --- a/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift +++ b/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift @@ -99,6 +99,8 @@ struct CLICompletionEngineTests { #expect(suggestions.contains("load")) #expect(suggestions.contains("get")) #expect(suggestions.contains("put")) + #expect(suggestions.contains("home")) + #expect(suggestions.contains("default")) #expect(suggestions.contains("save")) } diff --git a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift index 86a5939a7..e2d5db7f7 100644 --- a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift +++ b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift @@ -506,6 +506,23 @@ actor ParkingContactStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } @@ -542,6 +559,11 @@ actor ParkingContactStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} @@ -796,7 +818,7 @@ actor ParkingContactStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -804,10 +826,18 @@ actor ParkingContactStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} // swiftlint:disable:next function_parameter_count func saveNodeStatusSnapshot( diff --git a/MC1Widgets/Resources/pt.lproj/Localizable.strings b/MC1Widgets/Resources/pt.lproj/Localizable.strings new file mode 100644 index 000000000..0f0edfbf3 --- /dev/null +++ b/MC1Widgets/Resources/pt.lproj/Localizable.strings @@ -0,0 +1,16 @@ +/* Widget localization — European Portuguese (pt-PT) + AI-translated - please verify with native speakers. +*/ + +/* Connection status shown when radio is disconnected */ +"Disconnected" = "Desligado"; + + +/* Accessibility: packet rate */ +"%lld packets per minute" = "%lld pacotes por minuto"; + +/* Accessibility: battery level */ +"Battery %lld percent" = "Bateria a %lld por cento"; + +/* Control Center button: opens the app */ +"Open MeshCore One" = "Abrir MeshCore One"; diff --git a/MC1Widgets/Resources/pt.lproj/Localizable.stringsdict b/MC1Widgets/Resources/pt.lproj/Localizable.stringsdict new file mode 100644 index 000000000..f3f41ad54 --- /dev/null +++ b/MC1Widgets/Resources/pt.lproj/Localizable.stringsdict @@ -0,0 +1,43 @@ + + + + + + %lld unread + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + %lld por ler + other + %lld por ler + + + %lld unread messages + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + %lld mensagem não lida + other + %lld mensagens não lidas + + + + diff --git a/Makefile b/Makefile index 8a420994c..5cbf1b6b8 100644 --- a/Makefile +++ b/Makefile @@ -51,15 +51,33 @@ STORE_SUITES := \ # wedge every later run. The calling recipe sets `dest` first. SIM_LOCK = if [ -z "$$CI" ]; then \ lock="/tmp/mc1-xcodebuild-$$(printf '%s' "$$dest" | tr -c 'A-Za-z0-9' '-').lock"; \ + waited=0; \ while :; do \ if ( set -C; echo $$$$ > "$$lock" ) 2>/dev/null; then break; fi; \ owner=$$(cat "$$lock" 2>/dev/null); \ if [ -z "$$owner" ] || ! ps -p "$$owner" >/dev/null 2>&1; then rm -f "$$lock"; continue; fi; \ - echo "==> waiting for simulator lock ($$dest), held by pid $$owner"; sleep 2; \ + echo "==> waiting for simulator lock ($$dest), held by pid $$owner, $${waited}s elapsed"; sleep 2; \ + waited=$$((waited + 2)); \ done; \ trap 'rm -f "$$lock"' EXIT INT TERM HUP; \ fi +# Heartbeat runs in the pipe consumer so its trap cannot replace SIM_LOCK's +# lock-release trap. Kill it after xcsift; macOS bash skips pipeline exit traps. +XCODEBUILD_HEARTBEAT_SEC ?= 30 +XCSIFT_WITH_HEARTBEAT = { \ + echo "==> xcodebuild test ($$dest); xcsift summary at EOF" >&2; \ + SECONDS=0; \ + ( while sleep $(XCODEBUILD_HEARTBEAT_SEC); do echo "==> still running $${SECONDS}s" >&2; done ) & \ + hb=$$!; \ + trap 'kill $$hb 2>/dev/null || true' EXIT; \ + xcsift -f toon; \ + sift=$$?; \ + kill $$hb 2>/dev/null || true; \ + wait $$hb 2>/dev/null || true; \ + exit $$sift; \ + } + .DEFAULT_GOAL := help .PHONY: help generate test test-app test-store # `make test` runs two xcodebuild passes because a single invocation targets one OS: the full @@ -88,12 +106,18 @@ generate: dev.yml ## Regenerate MC1.xcodeproj from project.yml (xcodegen) test: test-app test-store ## Run everything: full app suite (iOS 26) + StoreKit suites (iOS 18) +# Skip sysdiagnose collection. xcodebuild can spawn simctl diagnose after the +# suite and block the recipe for minutes even when every test passed. test-app: generate ## Run the full app suite on iOS 26 (StoreKit suites auto-skip here) @dest='$(SIM)'; $(SIM_LOCK); \ xcodebuild test -project $(PROJECT) -scheme $(SCHEME) \ - -destination "$$dest" 2>&1 | xcsift -f toon + -destination "$$dest" \ + -collect-test-diagnostics never \ + 2>&1 | $(XCSIFT_WITH_HEARTBEAT) test-store: generate ## Run every StoreKit/IAP SKTestSession suite on iOS 18.x @dest='$(STORE_SIM)'; $(SIM_LOCK); \ xcodebuild test -project $(PROJECT) -scheme $(SCHEME) \ - -destination "$$dest" $(STORE_SUITES) 2>&1 | xcsift -f toon + -destination "$$dest" $(STORE_SUITES) \ + -collect-test-diagnostics never \ + 2>&1 | $(XCSIFT_WITH_HEARTBEAT) diff --git a/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift b/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift index 3bf4fcf31..7cc45aa42 100644 --- a/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift +++ b/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift @@ -76,30 +76,54 @@ public enum TransportCodeRegionResolver { return rawCode } - /// Find the matching region name for a packet, given a precomputed - /// `[(regionName, scopeKey)]` array. First match wins (mirrors firmware - /// iteration order). + /// Match a packet against every precomputed `[(regionName, scopeKey)]` entry. /// - /// Empty-array input short-circuits to nil. Callers (e.g. `RxLogService`) - /// own the cache and must rebuild it whenever the known-regions list - /// changes. - public static func findMatchingRegion( + /// Collects every name whose `calcTransportCode` equals + /// `expectedTransportCode0` (no first-hit early exit). Blank names are + /// dropped. Names are sorted with `localizedStandardCompare` so ambiguous + /// sets are order-stable. Empty input returns `.none`. Live cost is O(R) + /// HMACs per transport-coded packet; callers own and rebuild the cache. + public static func matchRegions( scopeKeys: [(name: String, key: Data)], expectedTransportCode0: UInt16, payloadTypeBits: UInt8, payload: Data - ) -> String? { - guard !scopeKeys.isEmpty else { return nil } + ) -> RegionMatchResult { + guard !scopeKeys.isEmpty else { return .none } + + var matchedNames: [String] = [] for (name, key) in scopeKeys { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } let code = calcTransportCode( scopeKey: key, payloadTypeBits: payloadTypeBits, payload: payload ) if code == expectedTransportCode0 { - return name + matchedNames.append(trimmed) } } - return nil + + let uniqueSorted = Array(Set(matchedNames)).sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + + switch uniqueSorted.count { + case 0: + return .none + case 1: + return .unique(uniqueSorted[0]) + default: + return .ambiguous(uniqueSorted) + } } } + +/// Result of matching a packet's `transport_codes[0]` against known public +/// region scope keys. Ambiguous sets always carry at least two sorted names. +public enum RegionMatchResult: Equatable, Sendable { + case none + case unique(String) + case ambiguous([String]) +} diff --git a/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift b/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift index e6d861da1..9775045a8 100644 --- a/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift +++ b/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift @@ -9,6 +9,17 @@ public protocol ContactSessionOps: Actor { /// - Throws: `MeshCoreError` if the contact query fails. func getContacts(since lastModified: Date?) async throws -> [MeshContact] + /// Retrieves contacts and the device's reported contact total. + /// + /// The total comes from the `contactsStart` header. On a full fetch + /// (`since == nil`) it is the device's complete contact count, so a caller + /// that prunes local rows can detect a truncated stream. + /// + /// - Parameter lastModified: An optional date for incremental synchronization. + /// - Returns: The contacts and the reported total. + /// - Throws: `MeshCoreError` if the contact query fails. + func getContactsReportingTotal(since lastModified: Date?) async throws -> ContactFetchResult + /// Fetches a single contact from the device by public key. /// /// - Parameter publicKey: The full 32-byte public key of the contact. diff --git a/MeshCore/Sources/MeshCore/Session/ContactFetchResult.swift b/MeshCore/Sources/MeshCore/Session/ContactFetchResult.swift new file mode 100644 index 000000000..11f8aacaf --- /dev/null +++ b/MeshCore/Sources/MeshCore/Session/ContactFetchResult.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A contact fetch paired with the device's reported contact total. +/// +/// The device sends the total in the `contactsStart` header at the start of a +/// `GET_CONTACTS` reply. On a full fetch (`since == nil`) the total is the +/// device's complete contact count, so a caller that prunes local rows can +/// compare it to `contacts.count` and skip the prune on a truncated stream. +/// +/// `reportedTotal` is `nil` when the reply completed without a `contactsStart` +/// header. The total is then unknown, so a prune caller must not treat the +/// received set as complete. +public struct ContactFetchResult: Sendable { + /// The contacts received in the reply. + public let contacts: [MeshContact] + + /// The contact total the device reported in the `contactsStart` header, or + /// `nil` if the header never arrived. + public let reportedTotal: Int? + + public init(contacts: [MeshContact], reportedTotal: Int?) { + self.contacts = contacts + self.reportedTotal = reportedTotal + } +} diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift index 8ebccba4d..dfe34096f 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift @@ -101,7 +101,29 @@ public extension MeshCoreSession { /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. func getContacts(since lastModified: Date? = nil) async throws -> [MeshContact] { - let (contacts, modifiedDate): ([MeshContact], Date?) = try await requestResponseSerializer.withSerialization { [self] in + try await fetchContacts(since: lastModified).contacts + } + + /// Fetches contacts and the device's reported contact total. + /// + /// The total comes from the `contactsStart` header. On a full fetch + /// (`since == nil`) it is the device's complete contact count, so a prune + /// caller can skip when received count is below that total. On an incremental + /// fetch the header still reports the full total, so the count is only a + /// completeness signal when `since == nil`. + /// + /// - Parameter lastModified: If provided, only returns contacts modified after this date. + /// - Returns: The contacts and the reported total. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. + func getContactsReportingTotal(since lastModified: Date? = nil) async throws -> ContactFetchResult { + let result = try await fetchContacts(since: lastModified) + return ContactFetchResult(contacts: result.contacts, reportedTotal: result.reportedTotal) + } + + private func fetchContacts( + since lastModified: Date? + ) async throws -> (contacts: [MeshContact], modifiedDate: Date?, reportedTotal: Int?) { + let (contacts, modifiedDate, reportedTotal): ([MeshContact], Date?, Int?) = try await requestResponseSerializer.withSerialization { [self] in let data = PacketBuilder.getContacts(since: lastModified) let (subscriptionID, events) = await dispatcher.subscribeTracked() @@ -114,12 +136,13 @@ public extension MeshCoreSession { // 3. Defers contactManager mutations until after the serialization closure // to avoid actor-isolation issues in the @Sendable closure. return try await withThrowingTaskGroup( - of: ([MeshContact], Date?).self + of: ([MeshContact], Date?, Int?).self ) { group in let progressTracker = StreamProgressTracker() group.addTask { var receivedContacts: [MeshContact] = [] var finalModifiedDate: Date? + var reportedTotal: Int? for await event in events { if Task.isCancelled { @@ -129,6 +152,7 @@ public extension MeshCoreSession { switch event { case let .contactsStart(count): await progressTracker.markProgress() + reportedTotal = count receivedContacts.reserveCapacity(count) case let .contact(contact): await progressTracker.markProgress() @@ -136,7 +160,7 @@ public extension MeshCoreSession { case let .contactsEnd(modifiedDate): await progressTracker.markProgress() finalModifiedDate = modifiedDate - return (receivedContacts, finalModifiedDate) + return (receivedContacts, finalModifiedDate, reportedTotal) case let .error(code): throw MeshCoreError.deviceError(code: code ?? 0) default: @@ -197,7 +221,7 @@ public extension MeshCoreSession { contactManager.markClean(lastModified: modifiedDate) } - return contacts + return (contacts, modifiedDate, reportedTotal) } /// Fetches a single contact from the device by public key. diff --git a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift index 786c30d85..714887f7a 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift @@ -263,6 +263,84 @@ struct MeshCoreSessionCommandCorrelationTests { await session.stop() } + @Test + func `getContactsReportingTotal surfaces the contactsStart total, not the received count`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration( + defaultTimeout: 10, + clientIdentifier: "MCTst", + contactStreamInactivityTimeout: 1.0, + contactStreamHardTimeout: 10.0 + ) + ) + + try await startSession(session, transport: transport) + + let fetchTask = Task { + try await session.getContactsReportingTotal() + } + + try await waitUntil("getContacts should be sent") { + await transport.sentData.count == 2 + } + + // Header reports 3, but the stream carries only 2 contacts before end. The + // reported total must be the header value so the prune caller can detect the + // truncation. + await transport.simulateReceive(makeContactsStartPacket(count: 3)) + for index in 0..<2 { + await transport.simulateReceive( + makeContactPacket(publicKey: Data(repeating: UInt8(index + 1), count: 32), name: "Node \(index)") + ) + } + await transport.simulateReceive(makeContactsEndPacket(lastModified: 1_704_067_200)) + + let result = try await fetchTask.value + #expect(result.contacts.count == 2) + #expect(result.reportedTotal == 3) + await session.stop() + } + + @Test + func `getContactsReportingTotal returns a nil total when contactsStart never arrives`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration( + defaultTimeout: 10, + clientIdentifier: "MCTst", + contactStreamInactivityTimeout: 1.0, + contactStreamHardTimeout: 10.0 + ) + ) + + try await startSession(session, transport: transport) + + let fetchTask = Task { + try await session.getContactsReportingTotal() + } + + try await waitUntil("getContacts should be sent") { + await transport.sentData.count == 2 + } + + // Contacts and end arrive with no start header. The total stays unknown so a + // prune caller cannot mistake this stream for a complete snapshot. + for index in 0..<2 { + await transport.simulateReceive( + makeContactPacket(publicKey: Data(repeating: UInt8(index + 1), count: 32), name: "Node \(index)") + ) + } + await transport.simulateReceive(makeContactsEndPacket(lastModified: 1_704_067_200)) + + let result = try await fetchTask.value + #expect(result.contacts.count == 2) + #expect(result.reportedTotal == nil) + await session.stop() + } + @Test func `getContacts times out after inactivity before contactsEnd`() async throws { let transport = MockTransport() diff --git a/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift b/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift index 5ceca8233..75b33ce15 100644 --- a/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift +++ b/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift @@ -129,7 +129,7 @@ struct TransportCodeRegionResolverTests { // MARK: - Region matching @Test - func `Round-trip: compute code then resolve back to region name`() throws { + func `Round-trip: compute code then resolve back to unique region name`() throws { let regionName = "Germany" let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: regionName)) let expectedCode = TransportCodeRegionResolver.calcTransportCode( @@ -139,28 +139,28 @@ struct TransportCodeRegionResolverTests { ) let scopeKeys: [(name: String, key: Data)] = [(regionName, scopeKey)] - let match = TransportCodeRegionResolver.findMatchingRegion( + let match = TransportCodeRegionResolver.matchRegions( scopeKeys: scopeKeys, expectedTransportCode0: expectedCode, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == regionName) + #expect(match == .unique(regionName)) } @Test - func `Empty scopeKeys returns nil`() { - let match = TransportCodeRegionResolver.findMatchingRegion( + func `Empty scopeKeys returns none`() { + let match = TransportCodeRegionResolver.matchRegions( scopeKeys: [], expectedTransportCode0: 0x1234, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == nil) + #expect(match == .none) } @Test - func `No matching region returns nil`() throws { + func `No matching region returns none`() throws { let germany = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) let usa = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "USA")) let actualCode = TransportCodeRegionResolver.calcTransportCode( @@ -174,17 +174,17 @@ struct TransportCodeRegionResolverTests { ("Germany", germany), ("USA", usa) ] - let match = TransportCodeRegionResolver.findMatchingRegion( + let match = TransportCodeRegionResolver.matchRegions( scopeKeys: scopeKeys, expectedTransportCode0: wrongCode, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == nil) + #expect(match == .none) } @Test - func `First match wins when iterating scopeKeys`() throws { + func `Ambiguous match returns all names sorted independent of input order`() throws { let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) let expectedCode = TransportCodeRegionResolver.calcTransportCode( scopeKey: scopeKey, @@ -192,17 +192,45 @@ struct TransportCodeRegionResolverTests { payload: samplePayload ) - // Same key listed twice under different names — first one wins. - let scopeKeys: [(name: String, key: Data)] = [ - ("FirstName", scopeKey), - ("SecondName", scopeKey) + // Same key under two names — both verify the transport code; never first-match only. + let forward: [(name: String, key: Data)] = [ + ("de-by", scopeKey), + ("de-hh", scopeKey) ] - let match = TransportCodeRegionResolver.findMatchingRegion( - scopeKeys: scopeKeys, + let reverse: [(name: String, key: Data)] = [ + ("de-hh", scopeKey), + ("de-by", scopeKey) + ] + + let forwardMatch = TransportCodeRegionResolver.matchRegions( + scopeKeys: forward, expectedTransportCode0: expectedCode, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == "FirstName") + let reverseMatch = TransportCodeRegionResolver.matchRegions( + scopeKeys: reverse, + expectedTransportCode0: expectedCode, + payloadTypeBits: groupTextPayloadBits, + payload: samplePayload + ) + + #expect(forwardMatch == .ambiguous(["de-by", "de-hh"])) + #expect(reverseMatch == .ambiguous(["de-by", "de-hh"])) + #expect(forwardMatch == reverseMatch) + } + + @Test + func `Dollar-prefixed private regions never match via empty scope key`() { + // deriveScopeKey returns nil for $ names, so callers never put them in the + // cache; matchRegions with an empty list is the production path. + #expect(TransportCodeRegionResolver.deriveScopeKey(regionName: "$secret") == nil) + let match = TransportCodeRegionResolver.matchRegions( + scopeKeys: [], + expectedTransportCode0: 0x1234, + payloadTypeBits: groupTextPayloadBits, + payload: samplePayload + ) + #expect(match == .none) } } diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index b41129c94..4ab0558fa 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -12,6 +12,7 @@ MeshCore One supports multiple languages. You can help improve translations enti | German | de | AI-translated | | Italian | it | Verified by corradoignoti #376 | | Polish | pl | AI-translated | +| Portuguese (Portugal) | pt | Verified by zadoke #413 | | Russian | ru | AI-translated | | Simplified Chinese | zh-Hans | Verified by MGJ520 #225 | | Spanish | es | AI-translated | @@ -152,7 +153,7 @@ Add the same key to all other language files. You can use AI translation as a st Use `.stringsdict` files for strings that change based on quantity. -#### Simple Languages (English, German, Dutch, Spanish, French) +#### Simple Languages (English, German, Dutch, Spanish, French, Italian, Portuguese) These languages use two forms: `one` (exactly 1) and `other` (0, 2+). diff --git a/docs/guides/WiFi_Transport.md b/docs/guides/WiFi_Transport.md index 2c55a4116..8ed65b12f 100644 --- a/docs/guides/WiFi_Transport.md +++ b/docs/guides/WiFi_Transport.md @@ -9,7 +9,7 @@ MeshCore One supports two transport types: - BLE (Bluetooth Low Energy) via `iOSBLETransport` in MC1Services - WiFi/TCP via `WiFiTransport` in MeshCore -WiFi is configured manually (host + port) and is typically used for fixed installations or devices that expose a TCP service. +WiFi is configured manually (hostname or IPv4 address + port) and is typically used for fixed installations or devices that expose a TCP service. Network.framework resolves the host at connect time, including mDNS `.local` names. The transport does not browse Bonjour services. ## Where the Code Lives @@ -45,7 +45,7 @@ The framed payload is the same MeshCore binary protocol payload used over BLE. import MeshCore let transport = WiFiTransport() -await transport.setConnectionInfo(host: "192.168.1.50", port: 5000) +await transport.setConnectionInfo(host: "radio.local", port: 5000) try await transport.connect() let session = MeshCoreSession(transport: transport) @@ -54,7 +54,7 @@ try await session.start() Notes: -- The transport itself does not implement discovery (mDNS/Bonjour) or keep-alives. +- The transport itself does not implement discovery (mDNS/Bonjour browsing) or keep-alives. Connecting to a hostname still uses Network.framework DNS, including mDNS for `.local`. - Reconnect behavior is handled at higher layers (e.g., `ConnectionManager`). - Use `Logger` for diagnostics; avoid `print()`. @@ -71,7 +71,7 @@ MeshCore One manages WiFi reconnection and connection health at the app layer: ## Troubleshooting - Verify the iPhone and device are on the same reachable network. -- Double check the host and port (MeshCore One defaults to port 5000 in the WiFi connection UI). +- Double check the hostname or IP and port (MeshCore One defaults to port 5000 in the WiFi connection UI). - If you have a dev machine on the same network, `nc -zv ` can help validate basic reachability. ## Further Reading diff --git a/project.yml b/project.yml index 90185ceae..a27ad9a22 100644 --- a/project.yml +++ b/project.yml @@ -27,7 +27,7 @@ packages: from: 1.5.0 MapLibre: url: https://github.com/maplibre/maplibre-gl-native-distribution - exactVersion: 6.25.1 + exactVersion: 6.28.0 MessagingUI: url: https://github.com/bwees-forks/swiftui-messaging-ui # Pinned to an exact revision: Package.resolved is gitignored (the whole @@ -79,7 +79,7 @@ targets: base: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.3.0 + MARKETING_VERSION: 1.4.0 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TARGETED_DEVICE_FAMILY: "1,2" @@ -129,7 +129,7 @@ targets: settings: base: INFOPLIST_FILE: MC1Tests/Info.plist - MARKETING_VERSION: 1.3.0 + MARKETING_VERSION: 1.4.0 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TEST_HOST: "$(BUILT_PRODUCTS_DIR)/MC1.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MC1" @@ -143,7 +143,7 @@ targets: - path: Shared settings: base: - MARKETING_VERSION: 1.3.0 + MARKETING_VERSION: 1.4.0 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TARGETED_DEVICE_FAMILY: "1,2"