From bc8fd1ed5676296cd6b29396bd10df844ecd1a3c Mon Sep 17 00:00:00 2001 From: UriyDevyataev <91690559+UriyDevyataev@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:57:36 +0700 Subject: [PATCH] sources sources+tests+ci --- .github/scripts/prepare-pod-build.rb | 94 + .github/scripts/select-simulator.py | 17 + .github/workflows/ci.yml | 216 +++ Package.swift | 140 ++ SimlaCore.podspec | 21 + SimlaOnlineConsultant.podspec | 21 + SimlaPushNotificationExtension.podspec | 22 + SimlaPushService.podspec | 25 + SimlaRecommendations.podspec | 21 + SimlaSDKConfiguration.podspec | 18 + SimlaSQLiteStorage.podspec | 19 + SimlaTracker.podspec | 22 + Sources/Core/Configuration/CrmSdkConfig.swift | 56 + .../OnlineConsultantCachePolicy.swift | 48 + .../OnlineConsultantConfig.swift | 56 + .../Core/Configuration/SessionConfig.swift | 15 + .../Core/Configuration/TrackerConfig.swift | 30 + Sources/Core/CoreError.swift | 146 ++ Sources/Core/CoreLocalization.swift | 31 + Sources/Core/CrmSdk.swift | 181 ++ Sources/Core/CrmUtmAttribution.swift | 51 + Sources/Core/Extensions/String.swift | 9 + Sources/Core/Helpers/AsyncBroadcaster.swift | 61 + Sources/Core/Network/APIEndpoints.swift | 3 + .../Core/Network/CrmSdkNetworkObserver.swift | 8 + Sources/Core/Network/NetworkSession.swift | 159 ++ Sources/Core/Request/ConnectPageContext.swift | 63 + Sources/Core/Request/ConnectRequest.swift | 33 + Sources/Core/Request/ConnectUser.swift | 44 + .../Resources/en.lproj/Localizable.strings | 15 + .../Resources/es.lproj/Localizable.strings | 15 + .../Resources/ru.lproj/Localizable.strings | 15 + Sources/Core/Response/ConnectBehaviour.swift | 49 + Sources/Core/Response/ConnectResponse.swift | 50 + Sources/Core/Response/ConnectedSession.swift | 11 + Sources/Core/Response/ConnectedUser.swift | 28 + .../Core/Response/SessionCredentials.swift | 18 + Sources/Core/Response/SessionID.swift | 31 + Sources/Core/SDKResourceBundle.swift | 18 + .../Core/Session/CrmSdkCustomerIdentity.swift | 58 + .../Session/SessionLifecycleListener.swift | 16 + Sources/Core/Session/SessionRuntime.swift | 330 ++++ Sources/Core/Session/UserTokenStorage.swift | 147 ++ .../ConsultantApplicationLifecycle.swift | 70 + .../Components/ConsultantFileCache.swift | 299 +++ .../ConsultantFileCacheStorage.swift | 117 ++ .../Components/ConsultantLocalization.swift | 103 + .../Components/ConsultantNetworkEngine.swift | 165 ++ .../ConsultantWebSocketEngine.swift | 121 ++ .../Components/ContactFormPresentation.swift | 70 + .../Components/RemoteFileCacheLoader.swift | 84 + .../OnlineConsultant/ConsultantClient.swift | 531 ++++++ .../ConsultantControllerError.swift | 47 + .../ContactFormActionBuilder.swift | 33 + .../ContactFormActionCoordinator.swift | 139 ++ .../ContactFormState.swift | 90 + .../ControllerEvent.swift | 28 + .../ControllerInternalState.swift | 23 + .../ControllerSnapshot.swift | 59 + .../ControllerState.swift | 24 + .../InputAttachmentStore.swift | 106 ++ .../MessageFileLinkCoordinator.swift | 289 +++ .../MessageHistoryCoordinator.swift | 60 + .../MessageReadCoordinator.swift | 113 ++ .../ConsultantController/MessageSender.swift | 279 +++ .../ConsultantController/MessageStore.swift | 446 +++++ .../ConsultantController/MessagesPage.swift | 24 + .../OnlineConsultant.swift | 1368 ++++++++++++++ .../OnlineConsultant/ConsultantError.swift | 154 ++ .../CrmSdk+OnlineConsultant.swift | 18 + .../OnlineConsultant/Extensions/Array.swift | 44 + .../Extensions/ConnectedUser.swift | 21 + .../OnlineConsultant/Extensions/String.swift | 58 + .../OnlineConsultant/Extensions/UIColor.swift | 43 + Sources/OnlineConsultant/Extensions/URL.swift | 34 + .../Models/Config/BackgroundTheme.swift | 16 + .../Models/Config/ConfigForm.swift | 41 + .../Models/Config/ConsultantConfig.swift | 246 +++ .../Models/Config/ContactFormDisplay.swift | 16 + .../Models/Config/DomainConfig.swift | 59 + .../Models/Config/ElementShape.swift | 16 + .../Models/Config/Links/ConsultantLink.swift | 228 +++ .../Config/Links/ConsultantLinksMode.swift | 87 + .../Links/ConsultantLinksOrientation.swift | 73 + .../Models/Config/Links/LinkSettings.swift | 28 + .../Models/Config/ManagerDisplayType.swift | 16 + .../ConsultantCacheStatistics.swift | 32 + .../Models/ContactForm/ContactForm.swift | 170 ++ .../Models/ContactForm/ContactFormField.swift | 18 + .../ContactForm/ContactFormFieldKey.swift | 119 ++ .../Models/ContactForm/ContactFormKind.swift | 9 + .../ContactForm/ContactFormPrefill.swift | 30 + .../ContactForm/ContactFormSubmitData.swift | 59 + .../Models/ContactForm/ContactFormUser.swift | 76 + .../Models/Core/ConsultantJSONValue.swift | 56 + .../Models/Core/FlexibleID.swift | 55 + .../Models/Message/ConsultantMessage.swift | 658 +++++++ .../Models/Message/MessageItem.swift | 209 +++ .../Message/MessageOrder/MessageOrder.swift | 151 ++ .../MessageOrder/MessageOrderDelivery.swift | 35 + .../MessageOrder/MessageOrderItem.swift | 56 + .../MessageOrder/MessageOrderPayment.swift | 46 + .../MessageOrderPaymentStatus.swift | 25 + .../MessageProduct/MessageProduct.swift | 40 + .../MessageProduct/MessageProductCost.swift | 56 + .../MessageProductQuantity.swift | 90 + .../Models/Message/MessageStatus.swift | 106 ++ .../Models/Message/MessageType.swift | 99 + .../Models/Message/MessageUser.swift | 57 + .../Message/SiteAction/SiteAction.swift | 234 +++ .../SiteAction/SiteActionExecution.swift | 38 + .../Message/SiteAction/SiteActionType.swift | 28 + .../Realtime/ConsultantOfflineStatus.swift | 19 + .../Realtime/ConsultantRealtimeEvent.swift | 81 + .../ConsultantContactFormRequest.swift | 36 + .../ConsultantSendMessageRequest.swift | 80 + .../ConsultantActionExecuteResponse.swift | 24 + .../Responses/ConsultantConnectResponse.swift | 99 + .../ConsultantFileLinkResponse.swift | 21 + .../ConsultantFileUploadResponse.swift | 13 + .../ConsultantMessagesResponse.swift | 22 + .../ConsultantSendMessageResponse.swift | 53 + .../Resources/ActionImages/confetti.png | Bin 0 -> 31247 bytes .../Resources/ActionImages/fire.png | Bin 0 -> 18668 bytes .../Resources/ActionImages/hello.png | Bin 0 -> 21209 bytes .../Resources/ActionImages/present.png | Bin 0 -> 24302 bytes .../Resources/Assets.xcassets/Contents.json | 6 + .../Contents.json | 16 + .../attachment.svg | 3 + .../Contents.json | 16 + .../\360\237\216\250 Icon color.svg" | 3 + .../Contents.json | 15 + .../consultant_close_icon.imageset/close.pdf | Bin 0 -> 4494 bytes .../Contents.json | 23 + .../Group 674.png | Bin 0 -> 4251 bytes .../Group 674@2x.png | Bin 0 -> 11659 bytes .../Group 674@3x.png | Bin 0 -> 21358 bytes .../Contents.json | 21 + .../\360\237\216\250 Icon color.svg" | 3 + .../Contents.json | 16 + .../consultant_send_icon.imageset/send.svg | 3 + .../Resources/en.lproj/Localizable.strings | 79 + .../Resources/es.lproj/Localizable.strings | 79 + .../Resources/ru.lproj/Localizable.strings | 79 + .../OnlineConsultant/SDKResourceBundle.swift | 18 + .../SwiftUI/OnlineConsultantObserver.swift | 145 ++ .../SwiftUI/OnlineConsultantScreen.swift | 27 + .../ContactFormMessageBubbleView.swift | 338 ++++ .../BubbleViews/FileMessageBubbleView.swift | 323 ++++ .../BubbleViews/ImageMessageBubbleView.swift | 244 +++ .../BubbleViews/OrderMessageBubbleView.swift | 464 +++++ .../ProductMessageBubbleView.swift | 410 ++++ .../BubbleViews/QuoteMessageBubbleView.swift | 262 +++ .../BubbleViews/TextMessageBubbleView.swift | 226 +++ .../Views/FileMessageItemView.swift | 202 ++ .../Views/ImageMessageItemView.swift | 373 ++++ .../Views/LinkedMessageTextView.swift | 128 ++ .../BubbleViews/Views/MessageBubbleView.swift | 134 ++ .../BubbleViews/Views/MetadataView.swift | 277 +++ .../BubbleViews/Views/OrderItemLinkView.swift | 101 + .../BubbleViews/Views/QuotePreviewView.swift | 167 ++ .../UI/Cells/CellReuseIdentifier.swift | 5 + .../InputAttachmentCollectionViewCell.swift | 296 +++ .../Layouts/ContactFormMessageLayout.swift | 22 + .../UI/Cells/Layouts/FileMessageLayout.swift | 64 + .../UI/Cells/Layouts/ImageMessageLayout.swift | 133 ++ .../UI/Cells/Layouts/MessageLayout.swift | 30 + .../UI/Cells/Layouts/OrderMessageLayout.swift | 57 + .../Cells/Layouts/ProductMessageLayout.swift | 79 + .../UI/Cells/Layouts/QuoteMessageLayout.swift | 74 + .../UI/Cells/Layouts/TextMessageLayout.swift | 40 + .../UI/Cells/MessageCollectionViewCell.swift | 1150 ++++++++++++ .../UI/Helpers/ActionImage.swift | 100 + .../UI/Helpers/DateParser.swift | 56 + .../UI/Helpers/FilePreviewResolver.swift | 56 + .../UI/Helpers/ImageLoader.swift | 118 ++ .../UI/Helpers/ImagePickerMetadata.swift | 121 ++ .../InputBarAttachmentCoordinator.swift | 558 ++++++ .../MessageAttachmentCoordinator.swift | 232 +++ .../UI/Helpers/MessageDateSeparator.swift | 53 + .../UI/Helpers/MessageGrouping.swift | 91 + .../UI/Helpers/MessageMetadataFormatter.swift | 45 + .../OnlineConsultantImageResource.swift | 132 ++ .../UI/Helpers/ProductImagePreviewURL.swift | 64 + .../UI/Models/AttachmentPickerError.swift | 38 + .../UI/Models/ContactFormMessageImage.swift | 16 + .../InputAttachment.swift | 78 + .../InputAttachmentStatus.swift | 28 + .../UI/Models/ManagerIdentity.swift | 23 + .../UI/Models/MessageDeliveryStatus.swift | 36 + .../UI/Models/MessageFileDisplayItem.swift | 46 + .../UI/Models/MessageOrderDisplayField.swift | 21 + .../UI/Models/MessageOrderDisplayItem.swift | 36 + .../UI/Models/Previews/FilePreviewItem.swift | 46 + .../UI/Models/Previews/ImagePreviewItem.swift | 74 + .../Models/Previews/MessageQuotePreview.swift | 11 + .../UI/Style/ConsultantTheme.swift | 167 ++ .../UI/Style/MessageBubbleStyle.swift | 74 + .../FilePreviewViewController.swift | 233 +++ .../OnlineConsultantViewController.swift | 445 +++++ .../ViewControllers/Views/AgreementView.swift | 137 ++ .../Views/ConsultantStateView.swift | 136 ++ .../Views/ConsultantView.swift | 1664 ++++++++++++++++ .../ContactForm/ContactFormContentView.swift | 909 +++++++++ .../Views/ContactForm/ContactFormView.swift | 244 +++ .../Views/FooterBrandingView.swift | 224 +++ .../Views/InputBar/InputBar.swift | 427 +++++ .../Views/InputBar/InputTextView.swift | 87 + .../CrmSdkNetworkObserver.swift | 8 + .../Models/PushMessage.swift | 52 + .../Models/PushNotificationStatus.swift | 19 + .../Models/PushStatusReportingError.swift | 34 + .../Models/PushStatusRequestBody.swift | 27 + .../Models/PushStatusRetryUpdate.swift | 65 + .../ObservedPushNetworkSession.swift | 39 + .../PushMessageParser.swift | 205 ++ .../PushNetworkSession.swift | 16 + .../PushNotificationHandler.swift | 205 ++ .../PushNotificationLocalization.swift | 21 + .../PushNotificationMetadata.swift | 3 + .../PushStatusReporter.swift | 146 ++ .../PushStatusRetryQueue.swift | 229 +++ .../PushStatusRetryStorage.swift | 109 ++ .../Resources/en.lproj/Localizable.strings | 5 + .../Resources/es.lproj/Localizable.strings | 5 + .../Resources/ru.lproj/Localizable.strings | 5 + .../SDKResourceBundle.swift | 18 + .../Components/PushLocalization.swift | 35 + Sources/PushService/CrmSdk+PushService.swift | 32 + .../Models/NotificationAction.swift | 19 + .../PushService/Models/PushServiceError.swift | 157 ++ Sources/PushService/Models/PushToken.swift | 43 + .../Models/PushTokenRequestBody.swift | 31 + .../PushService/PushAppNetworkSession.swift | 32 + Sources/PushService/PushHandler.swift | 150 ++ .../PushService/PushRegistrationStorage.swift | 107 ++ Sources/PushService/PushService+Version.swift | 6 + Sources/PushService/PushService.swift | 484 +++++ .../PushService/PushTokenRegistrator.swift | 88 + .../Resources/en.lproj/Localizable.strings | 18 + .../Resources/es.lproj/Localizable.strings | 18 + .../Resources/ru.lproj/Localizable.strings | 18 + Sources/PushService/SDKResourceBundle.swift | 18 + .../CrmSdk+Recommendations.swift | 16 + .../Models/ProductRecommendations.swift | 101 + .../ProductRecommendationsRequest.swift | 136 ++ .../Models/RecommendationsError.swift | 73 + .../Models/RecommendedProduct.swift | 55 + Sources/Recommendations/Recommendations.swift | 257 +++ .../RecommendationsConsentState.swift | 19 + .../RecommendationsLocalization.swift | 36 + .../Resources/en.lproj/Localizable.strings | 20 + .../Resources/es.lproj/Localizable.strings | 20 + .../Resources/ru.lproj/Localizable.strings | 20 + .../Recommendations/SDKResourceBundle.swift | 18 + .../SDKConfiguration/BackendEndpoints.swift | 36 + .../CrmSdkNetworkObserver.swift | 102 + Sources/SQLiteStorage/SQLiteDatabase.swift | 176 ++ Sources/Tracker/CrmSdk+Tracker.swift | 14 + .../Helpers/SQLiteTrackerStorage.swift | 210 +++ .../Tracker/Helpers/TrackerBatchBuilder.swift | 28 + .../Helpers/TrackerEventDataFactory.swift | 189 ++ .../Tracker/Helpers/TrackerEventQueue.swift | 65 + .../Helpers/TrackerEventTimestamp.swift | 19 + .../Tracker/Models/TrackerClientError.swift | 24 + .../Models/TrackerEventParameters.swift | 155 ++ .../Tracker/Models/TrackerEventUpdate.swift | 19 + .../Tracker/Models/TrackerPageContext.swift | 18 + Sources/Tracker/Models/TrackerRateLimit.swift | 71 + .../Models/TrackerServerTimeResponse.swift | 13 + .../Models/TrackerStoreEventsResponse.swift | 93 + .../Resources/en.lproj/Localizable.strings | 31 + .../Resources/es.lproj/Localizable.strings | 31 + .../Resources/ru.lproj/Localizable.strings | 31 + Sources/Tracker/SDKResourceBundle.swift | 18 + .../Tracker/State/TrackerConsentState.swift | 12 + Sources/Tracker/State/TrackerState.swift | 32 + Sources/Tracker/State/TrackerStateStore.swift | 55 + .../TimeSync/TrackerTimeSyncState.swift | 16 + .../TimeSync/TrackerTimeSyncStore.swift | 78 + Sources/Tracker/Tracker.swift | 1672 +++++++++++++++++ Sources/Tracker/TrackerClient.swift | 127 ++ Sources/Tracker/TrackerError.swift | 81 + .../Tracker/TrackerEvent/TrackerEvent.swift | 93 + .../TrackerEvent/TrackerEventDataValue.swift | 62 + Sources/Tracker/TrackerEventName.swift | 23 + Sources/Tracker/TrackerLocalization.swift | 47 + TestHost/AppDelegate.swift | 17 + TestHost/README.md | 19 + .../SDKTestHost.xcodeproj/project.pbxproj | 512 +++++ .../xcschemes/SDKHostedTests.xcscheme | 97 + .../APIEndpointsTests.swift | 28 + .../ConsultantSdkTests.swift | 148 ++ .../NetworkObserverTests.swift | 196 ++ .../NetworkSessionTests.swift | 110 ++ .../ConsultantApplicationLifecycleTests.swift | 63 + .../ConsultantConnectTests.swift | 42 + .../ConsultantFileCacheTests.swift | 310 +++ .../ConsultantImageLoaderTests.swift | 109 ++ .../ConsultantLayoutTests.swift | 113 ++ .../ConsultantMessageDateSeparatorTests.swift | 98 + .../ConsultantMessageGroupingTests.swift | 70 + ...onsultantMessageReadCoordinatorTests.swift | 138 ++ .../ConsultantMessageRetryTests.swift | 236 +++ .../ConsultantMessageStoreTests.swift | 111 ++ .../ConsultantMessageTypeTests.swift | 48 + .../ConsultantRealtimeEventTests.swift | 246 +++ .../ConsultantRealtimeLifecycleTests.swift | 168 ++ .../ConsultantViewScrollingTests.swift | 223 +++ .../ConsultantWebSocketTests.swift | 120 ++ .../OnlineConsultantConfigTests.swift | 38 + .../OnlineConsultantTests.swift | 30 + .../OnlineConsultantThemeTests.swift | 261 +++ .../ProductImagePreviewURLTests.swift | 24 + .../RemoteFileCacheLoaderTests.swift | 92 + .../NetworkObserverTests.swift | 74 + .../PushNotificationExtensionTests.swift | 194 ++ .../PushStatusReporterTests.swift | 375 ++++ .../PushStatusRetryStorageTests.swift | 67 + .../PushAutomaticRegistrationTests.swift | 396 ++++ .../PushNetworkSessionTests.swift | 87 + .../PushRegistrationStorageTests.swift | 63 + .../PushRetryTestStorage.swift | 12 + .../PushServicePublicAPITests.swift | 497 +++++ Tests/PushServiceTests/PushUtmTests.swift | 137 ++ .../RecommendationsTests.swift | 641 +++++++ .../SQLiteDatabaseTests.swift | 74 + .../SQLiteTrackerStorageTests.swift | 107 ++ .../TrackerBatchBuilderTests.swift | 35 + .../TrackerTests/TrackerReadinessTests.swift | 289 +++ Tests/TrackerTests/TrackerTests.swift | 527 ++++++ 331 files changed, 38415 insertions(+) create mode 100644 .github/scripts/prepare-pod-build.rb create mode 100644 .github/scripts/select-simulator.py create mode 100644 .github/workflows/ci.yml create mode 100644 Package.swift create mode 100644 SimlaCore.podspec create mode 100644 SimlaOnlineConsultant.podspec create mode 100644 SimlaPushNotificationExtension.podspec create mode 100644 SimlaPushService.podspec create mode 100644 SimlaRecommendations.podspec create mode 100644 SimlaSDKConfiguration.podspec create mode 100644 SimlaSQLiteStorage.podspec create mode 100644 SimlaTracker.podspec create mode 100644 Sources/Core/Configuration/CrmSdkConfig.swift create mode 100644 Sources/Core/Configuration/OnlineConsultantCachePolicy.swift create mode 100644 Sources/Core/Configuration/OnlineConsultantConfig.swift create mode 100644 Sources/Core/Configuration/SessionConfig.swift create mode 100644 Sources/Core/Configuration/TrackerConfig.swift create mode 100644 Sources/Core/CoreError.swift create mode 100644 Sources/Core/CoreLocalization.swift create mode 100644 Sources/Core/CrmSdk.swift create mode 100644 Sources/Core/CrmUtmAttribution.swift create mode 100644 Sources/Core/Extensions/String.swift create mode 100644 Sources/Core/Helpers/AsyncBroadcaster.swift create mode 100644 Sources/Core/Network/APIEndpoints.swift create mode 100644 Sources/Core/Network/CrmSdkNetworkObserver.swift create mode 100644 Sources/Core/Network/NetworkSession.swift create mode 100644 Sources/Core/Request/ConnectPageContext.swift create mode 100644 Sources/Core/Request/ConnectRequest.swift create mode 100644 Sources/Core/Request/ConnectUser.swift create mode 100644 Sources/Core/Resources/en.lproj/Localizable.strings create mode 100644 Sources/Core/Resources/es.lproj/Localizable.strings create mode 100644 Sources/Core/Resources/ru.lproj/Localizable.strings create mode 100644 Sources/Core/Response/ConnectBehaviour.swift create mode 100644 Sources/Core/Response/ConnectResponse.swift create mode 100644 Sources/Core/Response/ConnectedSession.swift create mode 100644 Sources/Core/Response/ConnectedUser.swift create mode 100644 Sources/Core/Response/SessionCredentials.swift create mode 100644 Sources/Core/Response/SessionID.swift create mode 100644 Sources/Core/SDKResourceBundle.swift create mode 100644 Sources/Core/Session/CrmSdkCustomerIdentity.swift create mode 100644 Sources/Core/Session/SessionLifecycleListener.swift create mode 100644 Sources/Core/Session/SessionRuntime.swift create mode 100644 Sources/Core/Session/UserTokenStorage.swift create mode 100644 Sources/OnlineConsultant/Components/ConsultantApplicationLifecycle.swift create mode 100644 Sources/OnlineConsultant/Components/ConsultantFileCache.swift create mode 100644 Sources/OnlineConsultant/Components/ConsultantFileCacheStorage.swift create mode 100644 Sources/OnlineConsultant/Components/ConsultantLocalization.swift create mode 100644 Sources/OnlineConsultant/Components/ConsultantNetworkEngine.swift create mode 100644 Sources/OnlineConsultant/Components/ConsultantWebSocketEngine.swift create mode 100644 Sources/OnlineConsultant/Components/ContactFormPresentation.swift create mode 100644 Sources/OnlineConsultant/Components/RemoteFileCacheLoader.swift create mode 100644 Sources/OnlineConsultant/ConsultantClient.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ConsultantControllerError.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ContactFormActionBuilder.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ContactFormActionCoordinator.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ContactFormState.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ControllerEvent.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ControllerInternalState.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ControllerSnapshot.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/ControllerState.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/InputAttachmentStore.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/MessageFileLinkCoordinator.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/MessageHistoryCoordinator.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/MessageReadCoordinator.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/MessageSender.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/MessageStore.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/MessagesPage.swift create mode 100644 Sources/OnlineConsultant/ConsultantController/OnlineConsultant.swift create mode 100644 Sources/OnlineConsultant/ConsultantError.swift create mode 100644 Sources/OnlineConsultant/CrmSdk+OnlineConsultant.swift create mode 100644 Sources/OnlineConsultant/Extensions/Array.swift create mode 100644 Sources/OnlineConsultant/Extensions/ConnectedUser.swift create mode 100644 Sources/OnlineConsultant/Extensions/String.swift create mode 100644 Sources/OnlineConsultant/Extensions/UIColor.swift create mode 100644 Sources/OnlineConsultant/Extensions/URL.swift create mode 100644 Sources/OnlineConsultant/Models/Config/BackgroundTheme.swift create mode 100644 Sources/OnlineConsultant/Models/Config/ConfigForm.swift create mode 100644 Sources/OnlineConsultant/Models/Config/ConsultantConfig.swift create mode 100644 Sources/OnlineConsultant/Models/Config/ContactFormDisplay.swift create mode 100644 Sources/OnlineConsultant/Models/Config/DomainConfig.swift create mode 100644 Sources/OnlineConsultant/Models/Config/ElementShape.swift create mode 100644 Sources/OnlineConsultant/Models/Config/Links/ConsultantLink.swift create mode 100644 Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksMode.swift create mode 100644 Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksOrientation.swift create mode 100644 Sources/OnlineConsultant/Models/Config/Links/LinkSettings.swift create mode 100644 Sources/OnlineConsultant/Models/Config/ManagerDisplayType.swift create mode 100644 Sources/OnlineConsultant/Models/Configuration/ConsultantCacheStatistics.swift create mode 100644 Sources/OnlineConsultant/Models/ContactForm/ContactForm.swift create mode 100644 Sources/OnlineConsultant/Models/ContactForm/ContactFormField.swift create mode 100644 Sources/OnlineConsultant/Models/ContactForm/ContactFormFieldKey.swift create mode 100644 Sources/OnlineConsultant/Models/ContactForm/ContactFormKind.swift create mode 100644 Sources/OnlineConsultant/Models/ContactForm/ContactFormPrefill.swift create mode 100644 Sources/OnlineConsultant/Models/ContactForm/ContactFormSubmitData.swift create mode 100644 Sources/OnlineConsultant/Models/ContactForm/ContactFormUser.swift create mode 100644 Sources/OnlineConsultant/Models/Core/ConsultantJSONValue.swift create mode 100644 Sources/OnlineConsultant/Models/Core/FlexibleID.swift create mode 100644 Sources/OnlineConsultant/Models/Message/ConsultantMessage.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageItem.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrder.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderDelivery.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderItem.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPayment.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPaymentStatus.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProduct.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductCost.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductQuantity.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageStatus.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageType.swift create mode 100644 Sources/OnlineConsultant/Models/Message/MessageUser.swift create mode 100644 Sources/OnlineConsultant/Models/Message/SiteAction/SiteAction.swift create mode 100644 Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionExecution.swift create mode 100644 Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionType.swift create mode 100644 Sources/OnlineConsultant/Models/Realtime/ConsultantOfflineStatus.swift create mode 100644 Sources/OnlineConsultant/Models/Realtime/ConsultantRealtimeEvent.swift create mode 100644 Sources/OnlineConsultant/Models/Requests/ConsultantContactFormRequest.swift create mode 100644 Sources/OnlineConsultant/Models/Requests/ConsultantSendMessageRequest.swift create mode 100644 Sources/OnlineConsultant/Models/Responses/ConsultantActionExecuteResponse.swift create mode 100644 Sources/OnlineConsultant/Models/Responses/ConsultantConnectResponse.swift create mode 100644 Sources/OnlineConsultant/Models/Responses/ConsultantFileLinkResponse.swift create mode 100644 Sources/OnlineConsultant/Models/Responses/ConsultantFileUploadResponse.swift create mode 100644 Sources/OnlineConsultant/Models/Responses/ConsultantMessagesResponse.swift create mode 100644 Sources/OnlineConsultant/Models/Responses/ConsultantSendMessageResponse.swift create mode 100644 Sources/OnlineConsultant/Resources/ActionImages/confetti.png create mode 100644 Sources/OnlineConsultant/Resources/ActionImages/fire.png create mode 100644 Sources/OnlineConsultant/Resources/ActionImages/hello.png create mode 100644 Sources/OnlineConsultant/Resources/ActionImages/present.png create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/Contents.json create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_attachment_icon.imageset/Contents.json create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_attachment_icon.imageset/attachment.svg create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/Contents.json create mode 100644 "Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/\360\237\216\250 Icon color.svg" create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/Contents.json create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/close.pdf create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Contents.json create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674.png create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@2x.png create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@3x.png create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/Contents.json create mode 100644 "Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/\360\237\216\250 Icon color.svg" create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/Contents.json create mode 100644 Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/send.svg create mode 100644 Sources/OnlineConsultant/Resources/en.lproj/Localizable.strings create mode 100644 Sources/OnlineConsultant/Resources/es.lproj/Localizable.strings create mode 100644 Sources/OnlineConsultant/Resources/ru.lproj/Localizable.strings create mode 100644 Sources/OnlineConsultant/SDKResourceBundle.swift create mode 100644 Sources/OnlineConsultant/SwiftUI/OnlineConsultantObserver.swift create mode 100644 Sources/OnlineConsultant/SwiftUI/OnlineConsultantScreen.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/ContactFormMessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/FileMessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/ImageMessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/OrderMessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/ProductMessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/QuoteMessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/TextMessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/FileMessageItemView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/ImageMessageItemView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/LinkedMessageTextView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MessageBubbleView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MetadataView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/OrderItemLinkView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/QuotePreviewView.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/CellReuseIdentifier.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/InputAttachmentCollectionViewCell.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/ContactFormMessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/FileMessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/ImageMessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/MessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/OrderMessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/ProductMessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/QuoteMessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/Layouts/TextMessageLayout.swift create mode 100644 Sources/OnlineConsultant/UI/Cells/MessageCollectionViewCell.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/ActionImage.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/DateParser.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/FilePreviewResolver.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/ImageLoader.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/ImagePickerMetadata.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/InputBarAttachmentCoordinator.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/MessageAttachmentCoordinator.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/MessageDateSeparator.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/MessageGrouping.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/MessageMetadataFormatter.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/OnlineConsultantImageResource.swift create mode 100644 Sources/OnlineConsultant/UI/Helpers/ProductImagePreviewURL.swift create mode 100644 Sources/OnlineConsultant/UI/Models/AttachmentPickerError.swift create mode 100644 Sources/OnlineConsultant/UI/Models/ContactFormMessageImage.swift create mode 100644 Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachment.swift create mode 100644 Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachmentStatus.swift create mode 100644 Sources/OnlineConsultant/UI/Models/ManagerIdentity.swift create mode 100644 Sources/OnlineConsultant/UI/Models/MessageDeliveryStatus.swift create mode 100644 Sources/OnlineConsultant/UI/Models/MessageFileDisplayItem.swift create mode 100644 Sources/OnlineConsultant/UI/Models/MessageOrderDisplayField.swift create mode 100644 Sources/OnlineConsultant/UI/Models/MessageOrderDisplayItem.swift create mode 100644 Sources/OnlineConsultant/UI/Models/Previews/FilePreviewItem.swift create mode 100644 Sources/OnlineConsultant/UI/Models/Previews/ImagePreviewItem.swift create mode 100644 Sources/OnlineConsultant/UI/Models/Previews/MessageQuotePreview.swift create mode 100644 Sources/OnlineConsultant/UI/Style/ConsultantTheme.swift create mode 100644 Sources/OnlineConsultant/UI/Style/MessageBubbleStyle.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/FilePreviewViewController.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/OnlineConsultantViewController.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/AgreementView.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantStateView.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantView.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormContentView.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormView.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/FooterBrandingView.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputBar.swift create mode 100644 Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputTextView.swift create mode 100644 Sources/PushNotificationExtension/CrmSdkNetworkObserver.swift create mode 100644 Sources/PushNotificationExtension/Models/PushMessage.swift create mode 100644 Sources/PushNotificationExtension/Models/PushNotificationStatus.swift create mode 100644 Sources/PushNotificationExtension/Models/PushStatusReportingError.swift create mode 100644 Sources/PushNotificationExtension/Models/PushStatusRequestBody.swift create mode 100644 Sources/PushNotificationExtension/Models/PushStatusRetryUpdate.swift create mode 100644 Sources/PushNotificationExtension/ObservedPushNetworkSession.swift create mode 100644 Sources/PushNotificationExtension/PushMessageParser.swift create mode 100644 Sources/PushNotificationExtension/PushNetworkSession.swift create mode 100644 Sources/PushNotificationExtension/PushNotificationHandler.swift create mode 100644 Sources/PushNotificationExtension/PushNotificationLocalization.swift create mode 100644 Sources/PushNotificationExtension/PushNotificationMetadata.swift create mode 100644 Sources/PushNotificationExtension/PushStatusReporter.swift create mode 100644 Sources/PushNotificationExtension/PushStatusRetryQueue.swift create mode 100644 Sources/PushNotificationExtension/PushStatusRetryStorage.swift create mode 100644 Sources/PushNotificationExtension/Resources/en.lproj/Localizable.strings create mode 100644 Sources/PushNotificationExtension/Resources/es.lproj/Localizable.strings create mode 100644 Sources/PushNotificationExtension/Resources/ru.lproj/Localizable.strings create mode 100644 Sources/PushNotificationExtension/SDKResourceBundle.swift create mode 100644 Sources/PushService/Components/PushLocalization.swift create mode 100644 Sources/PushService/CrmSdk+PushService.swift create mode 100644 Sources/PushService/Models/NotificationAction.swift create mode 100644 Sources/PushService/Models/PushServiceError.swift create mode 100644 Sources/PushService/Models/PushToken.swift create mode 100644 Sources/PushService/Models/PushTokenRequestBody.swift create mode 100644 Sources/PushService/PushAppNetworkSession.swift create mode 100644 Sources/PushService/PushHandler.swift create mode 100644 Sources/PushService/PushRegistrationStorage.swift create mode 100644 Sources/PushService/PushService+Version.swift create mode 100644 Sources/PushService/PushService.swift create mode 100644 Sources/PushService/PushTokenRegistrator.swift create mode 100644 Sources/PushService/Resources/en.lproj/Localizable.strings create mode 100644 Sources/PushService/Resources/es.lproj/Localizable.strings create mode 100644 Sources/PushService/Resources/ru.lproj/Localizable.strings create mode 100644 Sources/PushService/SDKResourceBundle.swift create mode 100644 Sources/Recommendations/CrmSdk+Recommendations.swift create mode 100644 Sources/Recommendations/Models/ProductRecommendations.swift create mode 100644 Sources/Recommendations/Models/ProductRecommendationsRequest.swift create mode 100644 Sources/Recommendations/Models/RecommendationsError.swift create mode 100644 Sources/Recommendations/Models/RecommendedProduct.swift create mode 100644 Sources/Recommendations/Recommendations.swift create mode 100644 Sources/Recommendations/RecommendationsConsentState.swift create mode 100644 Sources/Recommendations/RecommendationsLocalization.swift create mode 100644 Sources/Recommendations/Resources/en.lproj/Localizable.strings create mode 100644 Sources/Recommendations/Resources/es.lproj/Localizable.strings create mode 100644 Sources/Recommendations/Resources/ru.lproj/Localizable.strings create mode 100644 Sources/Recommendations/SDKResourceBundle.swift create mode 100644 Sources/SDKConfiguration/BackendEndpoints.swift create mode 100644 Sources/SDKConfiguration/CrmSdkNetworkObserver.swift create mode 100644 Sources/SQLiteStorage/SQLiteDatabase.swift create mode 100644 Sources/Tracker/CrmSdk+Tracker.swift create mode 100644 Sources/Tracker/Helpers/SQLiteTrackerStorage.swift create mode 100644 Sources/Tracker/Helpers/TrackerBatchBuilder.swift create mode 100644 Sources/Tracker/Helpers/TrackerEventDataFactory.swift create mode 100644 Sources/Tracker/Helpers/TrackerEventQueue.swift create mode 100644 Sources/Tracker/Helpers/TrackerEventTimestamp.swift create mode 100644 Sources/Tracker/Models/TrackerClientError.swift create mode 100644 Sources/Tracker/Models/TrackerEventParameters.swift create mode 100644 Sources/Tracker/Models/TrackerEventUpdate.swift create mode 100644 Sources/Tracker/Models/TrackerPageContext.swift create mode 100644 Sources/Tracker/Models/TrackerRateLimit.swift create mode 100644 Sources/Tracker/Models/TrackerServerTimeResponse.swift create mode 100644 Sources/Tracker/Models/TrackerStoreEventsResponse.swift create mode 100644 Sources/Tracker/Resources/en.lproj/Localizable.strings create mode 100644 Sources/Tracker/Resources/es.lproj/Localizable.strings create mode 100644 Sources/Tracker/Resources/ru.lproj/Localizable.strings create mode 100644 Sources/Tracker/SDKResourceBundle.swift create mode 100644 Sources/Tracker/State/TrackerConsentState.swift create mode 100644 Sources/Tracker/State/TrackerState.swift create mode 100644 Sources/Tracker/State/TrackerStateStore.swift create mode 100644 Sources/Tracker/TimeSync/TrackerTimeSyncState.swift create mode 100644 Sources/Tracker/TimeSync/TrackerTimeSyncStore.swift create mode 100644 Sources/Tracker/Tracker.swift create mode 100644 Sources/Tracker/TrackerClient.swift create mode 100644 Sources/Tracker/TrackerError.swift create mode 100644 Sources/Tracker/TrackerEvent/TrackerEvent.swift create mode 100644 Sources/Tracker/TrackerEvent/TrackerEventDataValue.swift create mode 100644 Sources/Tracker/TrackerEventName.swift create mode 100644 Sources/Tracker/TrackerLocalization.swift create mode 100644 TestHost/AppDelegate.swift create mode 100644 TestHost/README.md create mode 100644 TestHost/SDKTestHost.xcodeproj/project.pbxproj create mode 100644 TestHost/SDKTestHost.xcodeproj/xcshareddata/xcschemes/SDKHostedTests.xcscheme create mode 100644 Tests/ConsultantSdkTests/APIEndpointsTests.swift create mode 100644 Tests/ConsultantSdkTests/ConsultantSdkTests.swift create mode 100644 Tests/ConsultantSdkTests/NetworkObserverTests.swift create mode 100644 Tests/ConsultantSdkTests/NetworkSessionTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantApplicationLifecycleTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantConnectTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantFileCacheTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantImageLoaderTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantLayoutTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantMessageDateSeparatorTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantMessageGroupingTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantMessageReadCoordinatorTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantMessageRetryTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantMessageStoreTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantMessageTypeTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantRealtimeEventTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantRealtimeLifecycleTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantViewScrollingTests.swift create mode 100644 Tests/OnlineConsultantTests/ConsultantWebSocketTests.swift create mode 100644 Tests/OnlineConsultantTests/OnlineConsultantConfigTests.swift create mode 100644 Tests/OnlineConsultantTests/OnlineConsultantTests.swift create mode 100644 Tests/OnlineConsultantTests/OnlineConsultantThemeTests.swift create mode 100644 Tests/OnlineConsultantTests/ProductImagePreviewURLTests.swift create mode 100644 Tests/OnlineConsultantTests/RemoteFileCacheLoaderTests.swift create mode 100644 Tests/PushNotificationExtensionTests/NetworkObserverTests.swift create mode 100644 Tests/PushNotificationExtensionTests/PushNotificationExtensionTests.swift create mode 100644 Tests/PushNotificationExtensionTests/PushStatusReporterTests.swift create mode 100644 Tests/PushNotificationExtensionTests/PushStatusRetryStorageTests.swift create mode 100644 Tests/PushServiceTests/PushAutomaticRegistrationTests.swift create mode 100644 Tests/PushServiceTests/PushNetworkSessionTests.swift create mode 100644 Tests/PushServiceTests/PushRegistrationStorageTests.swift create mode 100644 Tests/PushServiceTests/PushRetryTestStorage.swift create mode 100644 Tests/PushServiceTests/PushServicePublicAPITests.swift create mode 100644 Tests/PushServiceTests/PushUtmTests.swift create mode 100644 Tests/RecommendationsTests/RecommendationsTests.swift create mode 100644 Tests/SQLiteStorageTests/SQLiteDatabaseTests.swift create mode 100644 Tests/TrackerTests/SQLiteTrackerStorageTests.swift create mode 100644 Tests/TrackerTests/TrackerBatchBuilderTests.swift create mode 100644 Tests/TrackerTests/TrackerReadinessTests.swift create mode 100644 Tests/TrackerTests/TrackerTests.swift diff --git a/.github/scripts/prepare-pod-build.rb b/.github/scripts/prepare-pod-build.rb new file mode 100644 index 0000000..ff53ff4 --- /dev/null +++ b/.github/scripts/prepare-pod-build.rb @@ -0,0 +1,94 @@ +require 'fileutils' +require 'pathname' +require 'xcodeproj' + +sdk_path = Pathname.new(ARGV.fetch(0)).expand_path +build_path = Pathname.new(ARGV.fetch(1)).expand_path +FileUtils.mkdir_p(build_path) +project = Xcodeproj::Project.new(build_path.join('PodBuild.xcodeproj')) +app = project.new_target(:application, 'PodBuild', :ios, '15.0') +notification_extension = project.new_target(:app_extension, 'NotificationService', :ios, '15.0') + +[app, notification_extension].each do |target| + target.build_configurations.each do |config| + config.build_settings.merge!( + 'SWIFT_VERSION' => '5.0', + 'GENERATE_INFOPLIST_FILE' => 'YES', + 'CURRENT_PROJECT_VERSION' => '1', + 'MARKETING_VERSION' => '1.0', + 'CODE_SIGNING_ALLOWED' => 'NO', + 'PRODUCT_BUNDLE_IDENTIFIER' => "com.simla.PodBuild#{target == app ? '' : '.NotificationService'}" + ) + end +end + +app.build_configurations.each do |config| + config.build_settings['INFOPLIST_KEY_UILaunchScreen_Generation'] = 'YES' +end +notification_extension.build_configurations.each do |config| + config.build_settings['INFOPLIST_FILE'] = 'NotificationInfo.plist' + config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'YES' +end +Xcodeproj::Plist.write_to_path( + { 'NSExtension' => { + 'NSExtensionPointIdentifier' => 'com.apple.usernotifications.service', + 'NSExtensionPrincipalClass' => '$(PRODUCT_MODULE_NAME).NotificationService' + } }, build_path.join('NotificationInfo.plist') +) + +app.add_dependency(notification_extension) +embed = app.new_copy_files_build_phase('Embed App Extensions') +embed.dst_subfolder_spec = '13' +embed.add_file_reference(notification_extension.product_reference) + +File.write(build_path.join('App.swift'), <<~SWIFT) + import UIKit + import Core + import OnlineConsultant + import Tracker + import Recommendations + import PushService + import PushNotificationExtension + + @main final class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + } +SWIFT +File.write(build_path.join('NotificationService.swift'), <<~SWIFT) + import UserNotifications + import PushNotificationExtension + + final class NotificationService: UNNotificationServiceExtension { + private let handler = PushNotificationHandler(siteToken: "ci-build") + } +SWIFT +app.add_file_references([project.main_group.new_file('App.swift')]) +notification_extension.add_file_references([project.main_group.new_file('NotificationService.swift')]) +project.save +scheme = Xcodeproj::XCScheme.new +scheme.add_build_target(app) +scheme.set_launch_target(app) +scheme.save_as(project.path, 'PodBuild', true) + +File.write(build_path.join('Podfile'), <<~PODS) + source 'https://cdn.cocoapods.org/' + platform :ios, '15.0' + use_frameworks! :linkage => :static + + sdk_path = #{sdk_path.to_s.dump} + target 'PodBuild' do + %w[ + SimlaCore SimlaOnlineConsultant SimlaTracker SimlaRecommendations + SimlaPushService SimlaPushNotificationExtension SimlaSDKConfiguration SimlaSQLiteStorage + ].each do |name| + pod name, :path => sdk_path + end + end + + target 'NotificationService' do + pod 'SimlaSDKConfiguration', :path => sdk_path + pod 'SimlaSQLiteStorage', :path => sdk_path + pod 'SimlaPushNotificationExtension', :path => sdk_path + end +PODS +puts "CocoaPods build project: #{build_path}" diff --git a/.github/scripts/select-simulator.py b/.github/scripts/select-simulator.py new file mode 100644 index 0000000..c2ba748 --- /dev/null +++ b/.github/scripts/select-simulator.py @@ -0,0 +1,17 @@ +import json +import os +import subprocess + +sdk = subprocess.check_output( + ['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-version'], text=True +).strip() +runtime = 'com.apple.CoreSimulator.SimRuntime.iOS-' + sdk.replace('.', '-') +devices = json.loads(subprocess.check_output( + ['xcrun', 'simctl', 'list', 'devices', 'available', '--json'], text=True +))['devices'].get(runtime, []) +device = next((item for item in devices if item['name'].startswith('iPhone')), None) +if device is None: + raise SystemExit('No available iPhone simulator for iOS ' + sdk) +print('Selected ' + device['name'] + ' (iOS ' + sdk + ')') +with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write('device_id=' + device['udid'] + '\n') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a18c770 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,216 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + DEVELOPER_DIR: /Applications/Xcode_26.5.app/Contents/Developer + +jobs: + builds: + name: ${{ matrix.name }} + runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - kind: spm + name: SPM build + - kind: pod + name: Pod build + env: + COCOAPODS_DISABLE_STATS: 'true' + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Show Xcode version + run: xcodebuild -version + + - name: Build all SDK modules for iOS + if: ${{ matrix.kind == 'spm' }} + shell: bash + run: | + set -euo pipefail + xcodebuild \ + -scheme ConsultantSdk-Package \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$RUNNER_TEMP/SimlaDerivedData" \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/SimlaSourcePackages" \ + build CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee "$RUNNER_TEMP/spm-build.log" + + - name: Build SPM tests for simulator + if: ${{ matrix.kind == 'spm' }} + shell: bash + run: | + set -euo pipefail + xcodebuild \ + -scheme ConsultantSdk-Package \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$RUNNER_TEMP/SimlaDerivedData" \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/SimlaSourcePackages" \ + build-for-testing CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee "$RUNNER_TEMP/spm-build-for-testing.log" + + - name: Package compiled tests + if: ${{ matrix.kind == 'spm' }} + shell: bash + run: | + set -euo pipefail + products="$RUNNER_TEMP/SimlaDerivedData/Build/Products" + shopt -s nullglob + test_runs=("$products/"*.xctestrun) + if [[ ${#test_runs[@]} -ne 1 ]]; then + echo "Expected exactly one xctestrun file, found ${#test_runs[@]}" >&2 + exit 1 + fi + tar -C "$products" -czf "$RUNNER_TEMP/spm-test-products.tar.gz" \ + Debug-iphonesimulator "$(basename "${test_runs[0]}")" + + - name: Upload compiled tests + if: ${{ matrix.kind == 'spm' }} + uses: actions/upload-artifact@v7 + with: + name: spm-test-products + path: ${{ runner.temp }}/spm-test-products.tar.gz + compression-level: 0 + retention-days: 1 + if-no-files-found: error + + - name: Upload SPM build logs + if: ${{ !cancelled() && matrix.kind == 'spm' }} + uses: actions/upload-artifact@v7 + with: + name: spm-build-results + retention-days: 7 + if-no-files-found: ignore + path: | + ${{ runner.temp }}/spm-build.log + ${{ runner.temp }}/spm-build-for-testing.log + + - name: Prepare CocoaPods application and extension + if: ${{ matrix.kind == 'pod' }} + run: | + pod --version + ruby .github/scripts/prepare-pod-build.rb \ + "$GITHUB_WORKSPACE" "$RUNNER_TEMP/SimlaPodBuild" + + - name: Install local SDK pods + if: ${{ matrix.kind == 'pod' }} + shell: bash + run: | + set -euo pipefail + cd "$RUNNER_TEMP/SimlaPodBuild" + pod install 2>&1 | tee "$RUNNER_TEMP/pod-install.log" + + - name: Build CocoaPods application and extension + if: ${{ matrix.kind == 'pod' }} + shell: bash + run: | + set -euo pipefail + xcodebuild \ + -workspace "$RUNNER_TEMP/SimlaPodBuild/PodBuild.xcworkspace" \ + -scheme PodBuild \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$RUNNER_TEMP/SimlaPodDerivedData" \ + build CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee "$RUNNER_TEMP/pod-build.log" + + - name: Upload CocoaPods build logs + if: ${{ !cancelled() && matrix.kind == 'pod' }} + uses: actions/upload-artifact@v7 + with: + name: pod-build-results + retention-days: 7 + if-no-files-found: ignore + path: | + ${{ runner.temp }}/pod-install.log + ${{ runner.temp }}/pod-build.log + + module_tests: + name: Tests / ${{ matrix.module }} + needs: builds + runs-on: macos-26 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - module: Core + target: ConsultantSdkTests + - module: OnlineConsultant + target: OnlineConsultantTests + - module: PushService + target: PushServiceTests + - module: PushNotificationExtension + target: PushNotificationExtensionTests + - module: Recommendations + target: RecommendationsTests + - module: Tracker + target: TrackerTests + - module: SQLiteStorage + target: SQLiteStorageTests + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Download compiled SPM tests + uses: actions/download-artifact@v8 + with: + name: spm-test-products + path: ${{ runner.temp }}/SimlaDerivedData/Build/Products + + - name: Select iPhone simulator + id: simulator + run: python3 .github/scripts/select-simulator.py + + - name: Run module tests + shell: bash + env: + TEST_TARGET: ${{ matrix.target }} + SIMULATOR_ID: ${{ steps.simulator.outputs.device_id }} + run: | + set -euo pipefail + products="$RUNNER_TEMP/SimlaDerivedData/Build/Products" + tar -xzf "$products/spm-test-products.tar.gz" -C "$products" + shopt -s nullglob + test_runs=("$products/"*.xctestrun) + if [[ ${#test_runs[@]} -ne 1 ]]; then + echo "Expected exactly one xctestrun file, found ${#test_runs[@]}" >&2 + exit 1 + fi + xcodebuild \ + -xctestrun "${test_runs[0]}" \ + -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + -only-testing:"$TEST_TARGET" \ + -parallel-testing-enabled NO \ + -resultBundlePath "$RUNNER_TEMP/$TEST_TARGET.xcresult" \ + test-without-building CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee "$RUNNER_TEMP/$TEST_TARGET.log" + + - name: Upload module test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: tests-${{ matrix.module }} + retention-days: 7 + if-no-files-found: ignore + path: | + ${{ runner.temp }}/${{ matrix.target }}.xcresult + ${{ runner.temp }}/${{ matrix.target }}.log diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a91702a --- /dev/null +++ b/Package.swift @@ -0,0 +1,140 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "ConsultantSdk", + defaultLocalization: "ru", + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: "Core", + targets: ["Core"] + ), + .library( + name: "PushService", + targets: ["PushService"] + ), + .library( + name: "PushNotificationExtension", + targets: ["PushNotificationExtension"] + ), + .library( + name: "OnlineConsultant", + targets: ["OnlineConsultant"] + ), + .library( + name: "Recommendations", + targets: ["Recommendations"] + ), + .library( + name: "Tracker", + targets: ["Tracker"] + ) + ], + dependencies: [ + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "12.17.0") + ], + targets: [ + .target(name: "SQLiteStorage", linkerSettings: [.linkedLibrary("sqlite3")]), + .target(name: "SDKConfiguration"), + .target( + name: "Core", + dependencies: ["SDKConfiguration"], + resources: [ + .process("Resources") + ], + linkerSettings: [ + .linkedFramework("Security") + ] + ), + .target( + name: "OnlineConsultant", + dependencies: ["Core", "SQLiteStorage"], + resources: [ + .process("Resources") + ] + ), + .target( + name: "PushService", + dependencies: [ + "Core", + "PushNotificationExtension", + "SQLiteStorage", + .product(name: "FirebaseCore", package: "firebase-ios-sdk"), + .product(name: "FirebaseMessaging", package: "firebase-ios-sdk"), + .product(name: "FirebaseInstallations", package: "firebase-ios-sdk"), + ], + resources: [ + .process("Resources") + ] + ), + .target( + name: "PushNotificationExtension", + dependencies: ["SQLiteStorage", "SDKConfiguration"], + resources: [ + .process("Resources") + ] + ), + .target( + name: "Recommendations", + dependencies: [ + "Core", + "Tracker" + ], + resources: [ + .process("Resources") + ] + ), + .target( + name: "Tracker", + dependencies: ["Core", "SQLiteStorage"], + resources: [ + .process("Resources") + ] + ), + .testTarget(name: "SQLiteStorageTests", dependencies: ["SQLiteStorage"]), + .testTarget( + name: "OnlineConsultantTests", + dependencies: [ + "Core", + "SQLiteStorage", + "OnlineConsultant" + ] + ), + .testTarget( + name: "ConsultantSdkTests", + dependencies: ["Core"] + ), + .testTarget( + name: "PushServiceTests", + dependencies: [ + "Core", + "SQLiteStorage", + "PushService", + "PushNotificationExtension" + ] + ), + .testTarget( + name: "PushNotificationExtensionTests", + dependencies: ["PushNotificationExtension"] + ), + .testTarget( + name: "RecommendationsTests", + dependencies: [ + "Core", + "Recommendations", + "Tracker" + ] + ), + .testTarget( + name: "TrackerTests", + dependencies: [ + "Core", + "Tracker" + ] + ), + ] +) diff --git a/SimlaCore.podspec b/SimlaCore.podspec new file mode 100644 index 0000000..6d44118 --- /dev/null +++ b/SimlaCore.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'SimlaCore' + s.version = '0.1.0' + s.summary = 'Shared configuration, sessions and networking for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'Core' + s.source_files = 'Sources/Core/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'PODS_DEVELOPMENT_LANGUAGE' => 'ru' + } + s.resource_bundles = { 'SimlaCore' => ['Sources/Core/Resources/**/*'] } + s.frameworks = 'Security' + s.dependency 'SimlaSDKConfiguration', s.version.to_s +end diff --git a/SimlaOnlineConsultant.podspec b/SimlaOnlineConsultant.podspec new file mode 100644 index 0000000..f5c9e58 --- /dev/null +++ b/SimlaOnlineConsultant.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'SimlaOnlineConsultant' + s.version = '0.1.0' + s.summary = 'Chat interface for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'OnlineConsultant' + s.source_files = 'Sources/OnlineConsultant/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'PODS_DEVELOPMENT_LANGUAGE' => 'ru' + } + s.resource_bundles = { 'SimlaOnlineConsultant' => ['Sources/OnlineConsultant/Resources/**/*'] } + s.dependency 'SimlaCore', s.version.to_s + s.dependency 'SimlaSQLiteStorage', s.version.to_s +end diff --git a/SimlaPushNotificationExtension.podspec b/SimlaPushNotificationExtension.podspec new file mode 100644 index 0000000..2be15a8 --- /dev/null +++ b/SimlaPushNotificationExtension.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'SimlaPushNotificationExtension' + s.version = '0.1.0' + s.summary = 'Notification service extension support for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'PushNotificationExtension' + s.source_files = 'Sources/PushNotificationExtension/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'PODS_DEVELOPMENT_LANGUAGE' => 'ru', + 'APPLICATION_EXTENSION_API_ONLY' => 'YES' + } + s.resource_bundles = { 'SimlaPushNotificationExtension' => ['Sources/PushNotificationExtension/Resources/**/*'] } + s.dependency 'SimlaSDKConfiguration', s.version.to_s + s.dependency 'SimlaSQLiteStorage', s.version.to_s +end diff --git a/SimlaPushService.podspec b/SimlaPushService.podspec new file mode 100644 index 0000000..6d81f97 --- /dev/null +++ b/SimlaPushService.podspec @@ -0,0 +1,25 @@ +Pod::Spec.new do |s| + s.name = 'SimlaPushService' + s.version = '0.1.0' + s.summary = 'Push notification registration and handling for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'PushService' + s.source_files = 'Sources/PushService/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'PODS_DEVELOPMENT_LANGUAGE' => 'ru' + } + s.resource_bundles = { 'SimlaPushService' => ['Sources/PushService/Resources/**/*'] } + s.dependency 'SimlaCore', s.version.to_s + s.dependency 'SimlaPushNotificationExtension', s.version.to_s + s.dependency 'FirebaseCore', '~> 12.17' + s.dependency 'FirebaseMessaging', '~> 12.17' + s.dependency 'FirebaseInstallations', '~> 12.17' + s.dependency 'SimlaSQLiteStorage', s.version.to_s +end diff --git a/SimlaRecommendations.podspec b/SimlaRecommendations.podspec new file mode 100644 index 0000000..3145280 --- /dev/null +++ b/SimlaRecommendations.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'SimlaRecommendations' + s.version = '0.1.0' + s.summary = 'Product recommendations for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'Recommendations' + s.source_files = 'Sources/Recommendations/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'PODS_DEVELOPMENT_LANGUAGE' => 'ru' + } + s.resource_bundles = { 'SimlaRecommendations' => ['Sources/Recommendations/Resources/**/*'] } + s.dependency 'SimlaCore', s.version.to_s + s.dependency 'SimlaTracker', s.version.to_s +end diff --git a/SimlaSDKConfiguration.podspec b/SimlaSDKConfiguration.podspec new file mode 100644 index 0000000..511a9e6 --- /dev/null +++ b/SimlaSDKConfiguration.podspec @@ -0,0 +1,18 @@ +Pod::Spec.new do |s| + s.name = 'SimlaSDKConfiguration' + s.version = '0.1.0' + s.summary = 'Shared backend configuration and HTTP observation for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'SDKConfiguration' + s.source_files = 'Sources/SDKConfiguration/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'APPLICATION_EXTENSION_API_ONLY' => 'YES' + } +end diff --git a/SimlaSQLiteStorage.podspec b/SimlaSQLiteStorage.podspec new file mode 100644 index 0000000..02b2224 --- /dev/null +++ b/SimlaSQLiteStorage.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'SimlaSQLiteStorage' + s.version = '0.1.0' + s.summary = 'Shared SQLite storage for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'SQLiteStorage' + s.source_files = 'Sources/SQLiteStorage/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'APPLICATION_EXTENSION_API_ONLY' => 'YES' + } + s.libraries = 'sqlite3' +end diff --git a/SimlaTracker.podspec b/SimlaTracker.podspec new file mode 100644 index 0000000..37aac00 --- /dev/null +++ b/SimlaTracker.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'SimlaTracker' + s.version = '0.1.0' + s.summary = 'Event tracking and persistent event storage for the Simla iOS SDK.' + s.homepage = 'https://github.com/simla-tech/MobileSdkIosTest' + s.license = { :type => 'Apache-2.0', :file => 'LICENSE' } + s.author = 'Simla' + s.source = { :git => 'https://github.com/simla-tech/MobileSdkIosTest.git', :tag => s.version.to_s } + s.ios.deployment_target = '15.0' + s.swift_version = '5.0' + s.module_name = 'Tracker' + s.source_files = 'Sources/Tracker/**/*.swift' + # Keep Swift package access shared between SDK modules, as in SPM. + s.pod_target_xcconfig = { + 'OTHER_SWIFT_FLAGS' => '$(inherited) -package-name ConsultantSdk', + 'PODS_DEVELOPMENT_LANGUAGE' => 'ru' + } + s.resource_bundles = { 'SimlaTracker' => ['Sources/Tracker/Resources/**/*'] } + s.libraries = 'sqlite3' + s.dependency 'SimlaCore', s.version.to_s + s.dependency 'SimlaSQLiteStorage', s.version.to_s +end diff --git a/Sources/Core/Configuration/CrmSdkConfig.swift b/Sources/Core/Configuration/CrmSdkConfig.swift new file mode 100644 index 0000000..084fe69 --- /dev/null +++ b/Sources/Core/Configuration/CrmSdkConfig.swift @@ -0,0 +1,56 @@ +import Foundation +import SDKConfiguration + +/// Shared configuration for Consultant SDK and its feature modules. +public struct CrmSdkConfig: Sendable { + /// Production backend used when no custom URL is provided. + public static let defaultBaseURL = SDKBackend.defaultBaseURL + + /// Backend HTTP(S) URL, with or without the `/api/client/v1` suffix. + public let baseURL: String + + /// Site token sent to the backend as the `X-Site-Token` header. + public let siteToken: String + + /// Default timeout for SDK network requests. + public let requestTimeout: TimeInterval + + /// Default maximum duration for SDK resource requests. + public let resourceTimeout: TimeInterval + + /// Optional HTTP observer; no events are retained by the SDK. + public let networkObserver: (any CrmSdkNetworkObserver)? + + public let consultantConfig: OnlineConsultantConfig + + /// Tracker batching and time synchronization settings. + public let trackerConfig: TrackerConfig + + /// Creates shared settings for the SDK and its modules. + /// + /// - Parameters: + /// - siteToken: Site token sent to the backend in `X-Site-Token`. + /// - baseURL: Absolute HTTP(S) backend URL without query or fragment. + /// - requestTimeout: Timeout for receiving data in an HTTP request, in seconds. + /// - resourceTimeout: Maximum duration of an HTTP request, in seconds. + /// - consultantConfig: Optional chat configuration; uses defaults when omitted. + /// - trackerConfig: Optional tracker configuration; uses defaults when omitted. + /// - networkObserver: Receives HTTP events asynchronously, including the initial connect request. + public init( + siteToken: String, + baseURL: String = CrmSdkConfig.defaultBaseURL, + requestTimeout: TimeInterval = 30, + resourceTimeout: TimeInterval = 60, + consultantConfig: OnlineConsultantConfig? = nil, + trackerConfig: TrackerConfig? = nil, + networkObserver: (any CrmSdkNetworkObserver)? = nil + ) { + self.networkObserver = networkObserver + self.siteToken = siteToken + self.baseURL = baseURL + self.requestTimeout = requestTimeout + self.resourceTimeout = resourceTimeout + self.consultantConfig = consultantConfig ?? OnlineConsultantConfig() + self.trackerConfig = trackerConfig ?? TrackerConfig() + } +} diff --git a/Sources/Core/Configuration/OnlineConsultantCachePolicy.swift b/Sources/Core/Configuration/OnlineConsultantCachePolicy.swift new file mode 100644 index 0000000..f8c3e1d --- /dev/null +++ b/Sources/Core/Configuration/OnlineConsultantCachePolicy.swift @@ -0,0 +1,48 @@ +// +// OnlineConsultantCachePolicy.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Foundation + +/// Defines how the SDK stores and cleans up cached remote files on disk. +public struct OnlineConsultantCachePolicy: Equatable, Sendable { + + /// The maximum total size, in bytes, allowed for cached files. + /// + /// When the value is `nil`, the SDK does not limit the cache by disk size. + public let maxDiskSizeBytes: Int64? + + /// The maximum age, in seconds, allowed for cached files. + /// + /// When the value is `nil`, the SDK does not remove cached files by age. + public let maxFileAge: TimeInterval? + + /// A Boolean value that determines whether the SDK applies this cleanup policy automatically. + /// + /// When set to `true`, the SDK calls cache trimming after storing or downloading cached files. + /// Trimming removes missing cache entries and then applies `maxFileAge` and `maxDiskSizeBytes`. + /// When set to `false`, cached files are kept until `trimCache()` or `clearCache()` is called. + public let automaticCleanup: Bool + + /// Creates a cache policy for files stored by the SDK. + /// + /// - Parameters: + /// - maxDiskSizeBytes: The maximum total size, in bytes, allowed for cached files. + /// Pass `nil` to disable size-based cleanup. + /// - maxFileAge: The maximum age, in seconds, allowed for cached files. + /// Pass `nil` to disable age-based cleanup. + /// - automaticCleanup: Whether the SDK should apply this cleanup policy automatically after + /// storing or downloading cached files. Pass `false` to manage cache cleanup manually. + public init( + maxDiskSizeBytes: Int64? = nil, + maxFileAge: TimeInterval? = nil, + automaticCleanup: Bool = true + ) { + self.maxDiskSizeBytes = maxDiskSizeBytes + self.maxFileAge = maxFileAge + self.automaticCleanup = automaticCleanup + } +} diff --git a/Sources/Core/Configuration/OnlineConsultantConfig.swift b/Sources/Core/Configuration/OnlineConsultantConfig.swift new file mode 100644 index 0000000..c88c4b1 --- /dev/null +++ b/Sources/Core/Configuration/OnlineConsultantConfig.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Defines local SDK settings used to connect to the Online Consultant backend and manage chat state. +public struct OnlineConsultantConfig: Sendable { + + /// The timeout, in seconds, used while opening the WebSocket connection. + public let connectTimeout: TimeInterval + + /// A Boolean value that determines whether the SDK reconnects the WebSocket after failures. + /// Foreground restoration after a background pause is independent of this setting. + public let autoReconnectWebSocket: Bool + + /// The delay, in seconds, before the SDK retries a failed WebSocket subscription. + /// Does not delay restoration when the application returns to the foreground. + public let webSocketReconnectDelay: TimeInterval + + /// The number of messages requested by default when loading chat history pages. + /// + /// The SDK clamps this value to the `1...1000` range before sending it as the `limit` query item. + public let messagesPageSize: Int + + /// The target number of messages the SDK tries to have after the initial connection. + /// + /// If the `connect` response contains fewer messages, the SDK loads the remaining amount with + /// `GET /messages`. The SDK clamps this value to the `0...1000` range. + public let initialMessagesLimit: Int + + /// The local disk cache policy used for cached file and image data. + public let cachePolicy: OnlineConsultantCachePolicy + + /// Creates configuration for the OnlineConsultant chat module. + /// + /// - Parameters: + /// - connectTimeout: The timeout, in seconds, used while opening the WebSocket connection. + /// - autoReconnectWebSocket: Whether the SDK should reconnect the WebSocket after failures. + /// - webSocketReconnectDelay: The delay, in seconds, before retrying a failed WebSocket + /// subscription. + /// - messagesPageSize: The default page size for `GET /messages` history requests. + /// - initialMessagesLimit: The target number of messages to load during the initial connection. + /// - cachePolicy: The local disk cache policy for cached file and image data. + public init( + connectTimeout: TimeInterval = 30, + autoReconnectWebSocket: Bool = true, + webSocketReconnectDelay: TimeInterval = 3, + messagesPageSize: Int = 50, + initialMessagesLimit: Int = 50, + cachePolicy: OnlineConsultantCachePolicy = OnlineConsultantCachePolicy() + ) { + self.connectTimeout = connectTimeout + self.autoReconnectWebSocket = autoReconnectWebSocket + self.webSocketReconnectDelay = webSocketReconnectDelay + self.messagesPageSize = messagesPageSize + self.initialMessagesLimit = initialMessagesLimit + self.cachePolicy = cachePolicy + } +} diff --git a/Sources/Core/Configuration/SessionConfig.swift b/Sources/Core/Configuration/SessionConfig.swift new file mode 100644 index 0000000..9404ae4 --- /dev/null +++ b/Sources/Core/Configuration/SessionConfig.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Configuration used by SessionRuntime to call the client API. +package struct SessionConfig: Equatable, Sendable { + + /// Site token sent to the backend as the `X-Site-Token` header. + package let siteToken: String + package let endpoints: APIEndpoints + + /// Creates SessionRuntime configuration. + package init(siteToken: String, baseURL: String = CrmSdkConfig.defaultBaseURL) { + self.siteToken = siteToken + self.endpoints = APIEndpoints(baseURL: baseURL) + } +} diff --git a/Sources/Core/Configuration/TrackerConfig.swift b/Sources/Core/Configuration/TrackerConfig.swift new file mode 100644 index 0000000..d1b1c54 --- /dev/null +++ b/Sources/Core/Configuration/TrackerConfig.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Configuration for tracker requests. +public struct TrackerConfig: Equatable, Sendable { + + /// Maximum number of events sent in one batch. + public let batchSize: Int + + /// Interval, in seconds, between periodic sends. Set `0` to disable periodic sends. + public let flushInterval: TimeInterval + + /// Interval, in seconds, between tracker server time synchronizations. + public let timeSyncInterval: TimeInterval + + /// Creates tracker SDK configuration. + /// + /// - Parameters: + /// - batchSize: Maximum number of tracker events sent in one batch (clamped to 1...50). + /// - flushInterval: Interval between periodic sends in seconds; `0` disables them. + /// - timeSyncInterval: Interval between server time synchronizations in seconds. + public init( + batchSize: Int = 50, + flushInterval: TimeInterval = 15, + timeSyncInterval: TimeInterval = 60 * 60 + ) { + self.batchSize = min(50, max(1, batchSize)) + self.flushInterval = flushInterval.isFinite ? min(86_400, max(0, flushInterval)) : 15 + self.timeSyncInterval = timeSyncInterval.isFinite ? min(86_400, max(0, timeSyncInterval)) : 3_600 + } +} diff --git a/Sources/Core/CoreError.swift b/Sources/Core/CoreError.swift new file mode 100644 index 0000000..4c55146 --- /dev/null +++ b/Sources/Core/CoreError.swift @@ -0,0 +1,146 @@ +import Foundation +import Security + +/// Errors returned by Core operations. +public enum CoreError: Error, Equatable, LocalizedError, Sendable { + + /// Customer identity contains an empty or oversized value. + case invalidCustomerIdentity(String) + + /// Connect response does not contain the user token or session id. + case missingConnectionCredentials + + /// Backend returned a non-success HTTP status with response messages. + case httpStatus(Int, [String]) + + /// The saved user token is not valid UTF-8. + case keychainInvalidTokenData + + /// A Keychain operation failed with the given OSStatus. + case keychainUnexpectedStatus(Int32) + + /// Data could not be encoded. + case requestEncodingFailed(String) + + /// Data could not be decoded. + case responseDecodingFailed(String) + + /// The connect request failed before receiving a response. + case requestFailed(String) + + /// The operation was cancelled. + case cancelled + + /// An unexpected operation error occurred. + case unexpected(String) + + public var errorDescription: String? { + switch self { + case .invalidCustomerIdentity(let message): + return message + case .missingConnectionCredentials: + return CoreLocalization.sessionMissingCredentials.localized + case .httpStatus(let statusCode, let messages): + let description = CoreLocalization.sessionHTTPStatus.format(statusCode) + return messages.isEmpty ? description : description + " " + messages.joined(separator: " ") + case .keychainInvalidTokenData: + return CoreLocalization.keychainInvalidTokenData.localized + case .keychainUnexpectedStatus(let status): + let description = CoreLocalization.keychainUnexpectedStatus.format(status) + guard let message = SecCopyErrorMessageString(status, nil) as String?, !message.isEmpty else { + return description + } + return description + " " + message + case .requestEncodingFailed(let message): + return CoreLocalization.requestEncodingFailed.format(message) + case .responseDecodingFailed(let message): + return CoreLocalization.responseDecodingFailed.format(message) + case .requestFailed(let message): + return CoreLocalization.requestFailed.format(message) + case .cancelled: + return CoreLocalization.cancelled.localized + case .unexpected(let message): + return CoreLocalization.unexpected.format(message) + } + } +} + +package extension CoreError { + + static func wrapping(_ error: Error) -> Self { + if let coreError = error as? Self { + return coreError + } + if error is CancellationError { + return .cancelled + } + if error is EncodingError { + return .requestEncodingFailed(error.localizedDescription) + } + if error is DecodingError { + return .responseDecodingFailed(error.localizedDescription) + } + if (error as NSError).domain == NSURLErrorDomain { + return .requestFailed(error.localizedDescription) + } + return .unexpected(error.localizedDescription) + } +} + +package extension CoreError { + + /// Extracts backend error messages from a raw HTTP response body. + static func errorMessages(from data: Data) -> [String] { + HTTPErrorResponseParser.messages(from: data, fallbackForEmptyMessages: false) { data in + guard let object = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) else { + return nil + } + + if let messages = object as? [String] { + return messages.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + if let response = object as? [String: Any] { + if let messages = response["errors"] as? [String] { + let nonemptyMessages = messages.filter { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + if !nonemptyMessages.isEmpty { return nonemptyMessages } + } + + for key in ["errors", "error", "message"] { + if let message = response[key] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return [message] + } + } + } + + if let message = object as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return [message] + } + + return [] + } + } +} + +package enum HTTPErrorResponseParser { + + package static func messages( + from data: Data, + fallbackForEmptyMessages: Bool, + parse: (Data) -> [String]? + ) -> [String] { + if let messages = parse(data), !messages.isEmpty || !fallbackForEmptyMessages { + return messages + } + + guard let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty else { + return [] + } + return [message] + } +} diff --git a/Sources/Core/CoreLocalization.swift b/Sources/Core/CoreLocalization.swift new file mode 100644 index 0000000..1fbcf24 --- /dev/null +++ b/Sources/Core/CoreLocalization.swift @@ -0,0 +1,31 @@ +import Foundation + +package enum CoreLocalization: String { + case customerExternalIDEmpty = "core.error.customer_external_id_empty" + case crmSiteCodeEmpty = "core.error.crm_site_code_empty" + case idEmpty = "core.error.customer_id_empty" + case customerIDNotPositive = "core.error.customer_id_not_positive" + case customerExternalIDTooLong = "core.error.customer_external_id_too_long" + case crmSiteCodeTooLong = "core.error.crm_site_code_too_long" + case keychainInvalidTokenData = "core.error.keychain_invalid_token_data" + case keychainUnexpectedStatus = "core.error.keychain_unexpected_status" + case sessionHTTPStatus = "core.error.session_http_status" + case sessionMissingCredentials = "core.error.session_missing_credentials" + case requestEncodingFailed = "core.error.request_encoding_failed" + case responseDecodingFailed = "core.error.response_decoding_failed" + case requestFailed = "core.error.request_failed" + case cancelled = "core.error.cancelled" + case unexpected = "core.error.unexpected" + + package var localized: String { + NSLocalizedString( + rawValue, + bundle: .module, + comment: "" + ) + } + + package func format(_ arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} diff --git a/Sources/Core/CrmSdk.swift b/Sources/Core/CrmSdk.swift new file mode 100644 index 0000000..2d75061 --- /dev/null +++ b/Sources/Core/CrmSdk.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Process-wide Consultant SDK instance. +@MainActor +public final class CrmSdk { + private static var instance: CrmSdk? + + /// Returns the initialized SDK instance. + public static var shared: CrmSdk { + guard let instance else { + preconditionFailure("Call Consultant.initialize(config:) before using Consultant.shared.") + } + return instance + } + + package let config: CrmSdkConfig + package let sessionRuntime: SessionRuntime + + private var modules: [ObjectIdentifier: AnyObject] = [:] + private var pushModuleKind: ConsultantPushModuleKind? + + /// Creates the process-wide SDK instance and waits for its shared connection. + /// Start modules after this method returns successfully. + /// + /// - Parameters: + /// - config: Shared SDK settings. + /// - customerIdentity: Optional retailCRM customer identity for the shared session. + /// - pageContext: Page or screen metadata for the initial connection. + @discardableResult + public static func initialize( + config: CrmSdkConfig, + customerIdentity: CrmSdkCustomerIdentity? = nil, + pageContext: ConnectPageContext = ConnectPageContext() + ) async throws -> CrmSdk { + let sdk = createInstance( + config: config, + customerIdentity: customerIdentity, + pageContext: pageContext + ) + try await sdk.startConnection() + return sdk + } + + private static func createInstance( + config: CrmSdkConfig, + customerIdentity: CrmSdkCustomerIdentity?, + pageContext: ConnectPageContext + ) -> CrmSdk { + precondition(instance == nil, "Consultant SDK is already initialized.") + + NetworkSession.configure( + requestTimeout: config.requestTimeout, + resourceTimeout: config.resourceTimeout, + observer: config.networkObserver + ) + + let consultant = CrmSdk( + config: config, + customerIdentity: customerIdentity, + pageContext: pageContext + ) + instance = consultant + return consultant + } + + private init( + config: CrmSdkConfig, + customerIdentity: CrmSdkCustomerIdentity?, + pageContext: ConnectPageContext + ) { + self.config = config + self.sessionRuntime = SessionRuntime( + config: SessionConfig(siteToken: config.siteToken, baseURL: config.baseURL), + customerIdentity: customerIdentity, + pageContext: pageContext + ) + } + + /// Switches the shared customer identity and reconnects the SDK. + /// + /// - Parameters: + /// - identity: New retailCRM customer identity; pass `nil` for an anonymous session. + public func setCustomerIdentity(_ identity: CrmSdkCustomerIdentity?) async throws { + do { + try await sessionRuntime.setCustomerIdentity(identity) + } catch { + throw CoreError.wrapping(error) + } + } + + /// Returns the existing module instance or creates it on first access. + package func module( + _ type: Module.Type, + create: () -> Module + ) -> Module { + let key = ObjectIdentifier(type) + if let module = modules[key] as? Module { + return module + } + + let module = create() + modules[key] = module + return module + } + + package func claimPushModule(_ kind: ConsultantPushModuleKind) { + guard let pushModuleKind else { + pushModuleKind = kind + return + } + precondition( + pushModuleKind == kind, + "PushService cannot be installed simultaneously." + ) + } + + private func startConnection() async throws { + do { + _ = try await sessionRuntime.start() + } catch { + throw CoreError.wrapping(error) + } + } +} + +/// Completion-based Core API. +public extension CrmSdk { + + /// Creates the process-wide SDK instance and starts its shared connection in the background. + /// Start modules after receiving success in the completion, which runs on the main actor. + /// The SDK instance is returned before the connection finishes. + /// + /// - Parameters: + /// - config: Shared SDK settings. + /// - customerIdentity: Optional retailCRM customer identity for the shared session. + /// - pageContext: Page or screen metadata for the initial connection. + /// - completion: Called on the main actor with the initial connection result. + @discardableResult + static func initialize( + config: CrmSdkConfig, + customerIdentity: CrmSdkCustomerIdentity? = nil, + pageContext: ConnectPageContext = ConnectPageContext(), + completion: @escaping (Result) -> Void + ) -> CrmSdk { + let sdk = createInstance( + config: config, + customerIdentity: customerIdentity, + pageContext: pageContext + ) + Task { + do { + try await sdk.startConnection() + completion(.success(())) + } catch { + completion(.failure(CoreError.wrapping(error))) + } + } + return sdk + } + + /// Switches the shared customer identity and reports the reconnection result. + /// The completion runs on the main actor. + func setCustomerIdentity( + _ identity: CrmSdkCustomerIdentity?, + completion: @escaping (Result) -> Void = { _ in } + ) { + Task { + do { + try await setCustomerIdentity(identity) + completion(.success(())) + } catch { + completion(.failure(CoreError.wrapping(error))) + } + } + } +} + +package enum ConsultantPushModuleKind { + case direct + case firebase +} diff --git a/Sources/Core/CrmUtmAttribution.swift b/Sources/Core/CrmUtmAttribution.swift new file mode 100644 index 0000000..0c5a670 --- /dev/null +++ b/Sources/Core/CrmUtmAttribution.swift @@ -0,0 +1,51 @@ +import Foundation + +package struct CrmUtmAttribution: Codable, Equatable, Sendable { + package let source: String? + package let medium: String? + package let campaign: String? + package let term: String? + package let content: String? + + package init?(source: String? = nil, medium: String? = nil, campaign: String? = nil, + term: String? = nil, content: String? = nil) { + func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value + } + self.source = normalized(source) + self.medium = normalized(medium) + self.campaign = normalized(campaign) + self.term = normalized(term) + self.content = normalized(content) + guard [self.source, self.medium, self.campaign, self.term, self.content].contains(where: { $0 != nil }) else { return nil } + } + + private enum CodingKeys: String, CodingKey { + case source = "utm_source" + case medium = "utm_medium" + case campaign = "utm_campaign" + case term = "utm_keyword" + case content = "utm_content" + } +} + +package final class CrmUtmAttributionStorage: @unchecked Sendable { + package static let shared = CrmUtmAttributionStorage() + private let lock = NSLock() + private var attribution: CrmUtmAttribution? + + package func save(_ attribution: CrmUtmAttribution) { + lock.withLock { self.attribution = attribution } + } + + package func load() -> CrmUtmAttribution? { + lock.withLock { attribution } + } + + package func clear() { + lock.withLock { attribution = nil } + } + + private init() {} +} diff --git a/Sources/Core/Extensions/String.swift b/Sources/Core/Extensions/String.swift new file mode 100644 index 0000000..99f2199 --- /dev/null +++ b/Sources/Core/Extensions/String.swift @@ -0,0 +1,9 @@ +import Foundation + +package extension String { + + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/Core/Helpers/AsyncBroadcaster.swift b/Sources/Core/Helpers/AsyncBroadcaster.swift new file mode 100644 index 0000000..c9d94ef --- /dev/null +++ b/Sources/Core/Helpers/AsyncBroadcaster.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Generic helper for `AsyncStream` fan-out. +/// +/// Stores active stream continuations, optionally emits an initial value to new subscribers, +/// broadcasts subsequent values to every subscriber, removes continuations on termination, and +/// finishes all streams when requested. +package final class AsyncBroadcaster: @unchecked Sendable { + + private let lock = NSLock() + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + package init() {} + + /// Creates a stream and optionally yields the current value immediately. + package func stream(initialValue: Value? = nil) -> AsyncStream { + AsyncStream { continuation in + let id = UUID() + lock.lock() + continuations[id] = continuation + lock.unlock() + + if let initialValue { + continuation.yield(initialValue) + } + + continuation.onTermination = { @Sendable [weak self] _ in + self?.removeContinuation(id: id) + } + } + } + + /// Broadcasts a value to all active streams. + package func yield(_ value: Value) { + currentContinuations().forEach { $0.yield(value) } + } + + /// Finishes all active streams and clears stored continuations. + package func finish() { + let currentContinuations = currentContinuations(removingAll: true) + currentContinuations.forEach { $0.finish() } + } + + private func removeContinuation(id: UUID) { + lock.lock() + continuations[id] = nil + lock.unlock() + } + + private func currentContinuations( + removingAll shouldRemoveAll: Bool = false + ) -> [AsyncStream.Continuation] { + lock.lock() + let currentContinuations = Array(continuations.values) + if shouldRemoveAll { + continuations.removeAll() + } + lock.unlock() + return currentContinuations + } +} diff --git a/Sources/Core/Network/APIEndpoints.swift b/Sources/Core/Network/APIEndpoints.swift new file mode 100644 index 0000000..08c9f79 --- /dev/null +++ b/Sources/Core/Network/APIEndpoints.swift @@ -0,0 +1,3 @@ +import SDKConfiguration + +package typealias APIEndpoints = BackendEndpoints diff --git a/Sources/Core/Network/CrmSdkNetworkObserver.swift b/Sources/Core/Network/CrmSdkNetworkObserver.swift new file mode 100644 index 0000000..a53f5af --- /dev/null +++ b/Sources/Core/Network/CrmSdkNetworkObserver.swift @@ -0,0 +1,8 @@ +import SDKConfiguration + +/// Receives SDK HTTP request, completion, and optional metrics events. +public typealias CrmSdkNetworkObserver = SDKConfiguration.CrmSdkNetworkObserver +/// A prepared HTTP request and its identifier and start time. +public typealias CrmSdkNetworkRequest = SDKConfiguration.CrmSdkNetworkRequest +/// The HTTP transport result, correlated with its request identifier. +public typealias CrmSdkNetworkResponse = SDKConfiguration.CrmSdkNetworkResponse diff --git a/Sources/Core/Network/NetworkSession.swift b/Sources/Core/Network/NetworkSession.swift new file mode 100644 index 0000000..8afc5b7 --- /dev/null +++ b/Sources/Core/Network/NetworkSession.swift @@ -0,0 +1,159 @@ +import Foundation +import SDKConfiguration + +/// The SDK's process-wide URL session and HTTP timeout policy. +package enum NetworkSession { + private static let lock = NSLock() + private static var requestTimeout: TimeInterval = 30 + private static var resourceTimeout: TimeInterval = 60 + private static var observationDelivery: NetworkObservationDelivery? + + /// WebSocket tasks share this session. Its resource timeout stays at the system default + /// so an open WebSocket is not closed by the shorter HTTP deadline. + package static let shared = URLSession(configuration: .default) + + package static func configure( + requestTimeout: TimeInterval, resourceTimeout: TimeInterval, + observer: (any CrmSdkNetworkObserver)? = nil + ) { + let delivery = observer.map { NetworkObservationDelivery(observer: $0) } + lock.lock() + self.requestTimeout = requestTimeout + self.resourceTimeout = resourceTimeout + self.observationDelivery = delivery + lock.unlock() + } + + package static func data( + for request: URLRequest, + in session: URLSession = shared + ) async throws -> (Data, URLResponse) { + let cancellation = NetworkTaskCancellation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let task = dataTask( + with: request, in: session + ) { data, response, error in + if let error { + continuation.resume(throwing: error) + } else if let data, let response { + continuation.resume(returning: (data, response)) + } else { + continuation.resume(throwing: URLError(.badServerResponse)) + } + } + cancellation.set(task) + task.resume() + } + } onCancel: { + cancellation.cancel() + } + } + + package static func dataTask( + with request: URLRequest, + in session: URLSession = shared, + completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void + ) -> URLSessionDataTask { + let (request, timeout, delivery) = prepared(request) + let observation = delivery?.begin(request: request) + let deadline = NetworkTaskDeadline() + let task = session.dataTask(with: request) { data, response, error in + let timedOut = deadline.cancel() + let finalError = timedOut ? URLError(.timedOut) + : (error ?? (response == nil ? URLError(.badServerResponse) : nil)) + observation?.finish(response: response, data: data, error: finalError) + completionHandler(data, response, finalError) + } + observation?.attachMetrics(to: task, in: session) + deadline.schedule(task: task, after: timeout) + return task + } + + package static func downloadTask( + with url: URL, + in session: URLSession = shared, + completionHandler: @escaping (URL?, URLResponse?, Error?) -> Void + ) -> URLSessionDownloadTask { + let (request, timeout, delivery) = prepared(URLRequest(url: url)) + let observation = delivery?.begin(request: request) + let deadline = NetworkTaskDeadline() + let task = session.downloadTask(with: request) { url, response, error in + let timedOut = deadline.cancel() + let finalError = timedOut ? URLError(.timedOut) + : (error ?? (response == nil ? URLError(.badServerResponse) : nil)) + observation?.finish(response: response, data: nil, error: finalError) + completionHandler(url, response, finalError) + } + observation?.attachMetrics(to: task, in: session) + deadline.schedule(task: task, after: timeout) + return task + } + + private static func prepared(_ request: URLRequest) -> (URLRequest, TimeInterval, NetworkObservationDelivery?) { + lock.lock() + let requestTimeout = self.requestTimeout + let resourceTimeout = self.resourceTimeout + let delivery = self.observationDelivery + lock.unlock() + + var request = request + request.timeoutInterval = requestTimeout + return (request, resourceTimeout, delivery) + } +} + +private final class NetworkTaskCancellation: @unchecked Sendable { + private let lock = NSLock() + private var task: URLSessionTask? + private var isCancelled = false + + func set(_ task: URLSessionTask) { + lock.lock() + self.task = task + let isCancelled = self.isCancelled + lock.unlock() + if isCancelled { task.cancel() } + } + + func cancel() { + lock.lock() + isCancelled = true + let task = self.task + lock.unlock() + task?.cancel() + } +} + +private final class NetworkTaskDeadline { + private let lock = NSLock() + private var timer: DispatchSourceTimer? + private var timedOut = false + + func schedule(task: URLSessionTask, after interval: TimeInterval) { + let timer = DispatchSource.makeTimerSource(queue: .global()) + timer.schedule(deadline: .now() + max(0, interval)) + timer.setEventHandler { [weak self, weak task] in + guard let self else { return } + self.lock.lock() + let isActive = self.timer != nil + if isActive { self.timedOut = true } + self.lock.unlock() + if isActive { task?.cancel() } + } + lock.lock() + self.timer = timer + lock.unlock() + timer.resume() + } + + func cancel() -> Bool { + lock.lock() + let timer = self.timer + self.timer = nil + let timedOut = self.timedOut + lock.unlock() + timer?.cancel() + return timedOut + } +} diff --git a/Sources/Core/Request/ConnectPageContext.swift b/Sources/Core/Request/ConnectPageContext.swift new file mode 100644 index 0000000..09ee4b3 --- /dev/null +++ b/Sources/Core/Request/ConnectPageContext.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Page or screen metadata sent while initializing a backend session. +public struct ConnectPageContext: Codable, Equatable, Sendable { + + /// Current page or screen title encoded as `page_title`. + public let title: String? + + /// URL used for tracker event filtering on the backend. + public let url: URL? + + /// Current URL encoded as `refer_url` for backend session metadata. + public let referURL: URL? + + /// Creates page or screen metadata for the connect request. + /// + /// - Parameters: + /// - title: Optional title of the current screen or page. + /// - url: Optional page URL used for backend tracker filtering. + /// - referURL: Optional URL sent as `refer_url` in the connect request. + public init( + title: String? = nil, + url: URL? = nil, + referURL: URL? = nil + ) { + self.title = title + self.url = url + self.referURL = referURL + } +} + +extension ConnectPageContext { + private struct CodingValue: Codable { + let title: String? + let url: URL? + let referURL: URL? + } + + /// Decodes a `ConnectPageContext` value from its serialized representation. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + do { + let value = try CodingValue(from: decoder) + self.init(title: value.title, url: value.url, referURL: value.referURL) + } catch { + throw CoreError.wrapping(error) + } + } + + /// Encodes this `ConnectPageContext` value for serialization. + /// + /// - Parameters: + /// - encoder: Encoder receiving this value. + public func encode(to encoder: Encoder) throws { + do { + try CodingValue(title: title, url: url, referURL: referURL).encode(to: encoder) + } catch { + throw CoreError.wrapping(error) + } + } +} diff --git a/Sources/Core/Request/ConnectRequest.swift b/Sources/Core/Request/ConnectRequest.swift new file mode 100644 index 0000000..89d603d --- /dev/null +++ b/Sources/Core/Request/ConnectRequest.swift @@ -0,0 +1,33 @@ +/// The request body sent to `POST /api/client/v1/connect`. +package struct ConnectRequest: Encodable, Equatable, Sendable { + + /// User data used to restore an existing user or create a new one. + package let user: ConnectUser + + /// Current page or screen title encoded as `page_title`. + package let pageTitle: String? + + /// Current page URL used for tracker event filtering. + package let url: String? + + /// Current page URL encoded as `refer_url` for backend session metadata. + package let referURL: String? + + /// Creates a connect request from user identity data and page context metadata. + package init( + user: ConnectUser = ConnectUser(), + pageContext: ConnectPageContext = ConnectPageContext() + ) { + self.user = user + self.pageTitle = pageContext.title + self.url = pageContext.url?.absoluteString + self.referURL = pageContext.referURL?.absoluteString + } + + private enum CodingKeys: String, CodingKey { + case user + case pageTitle = "page_title" + case url + case referURL = "refer_url" + } +} diff --git a/Sources/Core/Request/ConnectUser.swift b/Sources/Core/Request/ConnectUser.swift new file mode 100644 index 0000000..91ccd13 --- /dev/null +++ b/Sources/Core/Request/ConnectUser.swift @@ -0,0 +1,44 @@ +/// User identity and optional contact data sent in the `user` object of `POST /connect`. +package struct ConnectUser: Codable, Equatable, Sendable { + + /// Previously issued user token returned by the backend. + package let token: String? + + /// Preferred user language, for example `ru`, `en`, or `en-US`. + package let language: String? + + /// User display name. + package let name: String? + + /// User phone number. + package let phone: String? + + /// User email address. + package let email: String? + + /// Creates user data for the connect request. + package init( + token: String? = nil, + language: String? = nil, + name: String? = nil, + phone: String? = nil, + email: String? = nil + ) { + self.token = token + self.language = language + self.name = name + self.phone = phone + self.email = email + } + + /// Returns a copy with the provided token. + package func withToken(_ token: String?) -> ConnectUser { + ConnectUser( + token: token, + language: language, + name: name, + phone: phone, + email: email + ) + } +} diff --git a/Sources/Core/Resources/en.lproj/Localizable.strings b/Sources/Core/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..240b290 --- /dev/null +++ b/Sources/Core/Resources/en.lproj/Localizable.strings @@ -0,0 +1,15 @@ +"core.error.customer_external_id_empty" = "Customer external ID must not be blank."; +"core.error.crm_site_code_empty" = "CRM site code must not be blank."; +"core.error.customer_id_empty" = "Customer ID or external ID is required."; +"core.error.customer_id_not_positive" = "Customer ID must be greater than zero."; +"core.error.customer_external_id_too_long" = "Customer external ID must not exceed 255 characters."; +"core.error.crm_site_code_too_long" = "CRM site code must not exceed 255 characters."; +"core.error.keychain_invalid_token_data" = "Stored consultant user token is not valid UTF-8."; +"core.error.keychain_unexpected_status" = "Keychain operation failed with status %d."; +"core.error.session_http_status" = "Connect failed with HTTP status %d."; +"core.error.session_missing_credentials" = "Connect response does not contain session credentials."; +"core.error.request_encoding_failed" = "Could not encode data: %@"; +"core.error.response_decoding_failed" = "Could not decode data: %@"; +"core.error.request_failed" = "Connect request failed: %@"; +"core.error.cancelled" = "Connection operation was cancelled."; +"core.error.unexpected" = "Consultant core operation failed: %@"; diff --git a/Sources/Core/Resources/es.lproj/Localizable.strings b/Sources/Core/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..3c20a1b --- /dev/null +++ b/Sources/Core/Resources/es.lproj/Localizable.strings @@ -0,0 +1,15 @@ +"core.error.customer_external_id_empty" = "El ID externo del cliente no puede estar vacío."; +"core.error.crm_site_code_empty" = "El código del sitio de CRM no puede estar vacío."; +"core.error.customer_id_empty" = "Se requiere el ID del cliente o su ID externo."; +"core.error.customer_id_not_positive" = "El ID del cliente debe ser mayor que cero."; +"core.error.customer_external_id_too_long" = "El ID externo del cliente no debe superar los 255 caracteres."; +"core.error.crm_site_code_too_long" = "El código del sitio de CRM no debe superar los 255 caracteres."; +"core.error.keychain_invalid_token_data" = "El token de usuario del consultor guardado no es un valor UTF-8 válido."; +"core.error.keychain_unexpected_status" = "La operación de Keychain falló con el estado %d."; +"core.error.session_http_status" = "La conexión falló con el estado HTTP %d."; +"core.error.session_missing_credentials" = "La respuesta de conexión no contiene las credenciales de sesión."; +"core.error.request_encoding_failed" = "No se pudieron codificar los datos: %@"; +"core.error.response_decoding_failed" = "No se pudieron decodificar los datos: %@"; +"core.error.request_failed" = "La solicitud de conexión falló: %@"; +"core.error.cancelled" = "Se canceló la operación de conexión."; +"core.error.unexpected" = "La operación de Core falló: %@"; diff --git a/Sources/Core/Resources/ru.lproj/Localizable.strings b/Sources/Core/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000..d11f959 --- /dev/null +++ b/Sources/Core/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,15 @@ +"core.error.customer_external_id_empty" = "Внешний ID клиента не должен быть пустым."; +"core.error.crm_site_code_empty" = "Код сайта CRM не должен быть пустым."; +"core.error.customer_id_empty" = "Необходимо указать ID клиента или внешний ID."; +"core.error.customer_id_not_positive" = "ID клиента должен быть больше нуля."; +"core.error.customer_external_id_too_long" = "Внешний ID клиента не должен превышать 255 символов."; +"core.error.crm_site_code_too_long" = "Код сайта CRM не должен превышать 255 символов."; +"core.error.keychain_invalid_token_data" = "Сохранённый токен пользователя консультанта не является корректной строкой UTF-8."; +"core.error.keychain_unexpected_status" = "Операция Keychain завершилась со статусом %d."; +"core.error.session_http_status" = "Подключение завершилось с HTTP-статусом %d."; +"core.error.session_missing_credentials" = "Ответ подключения не содержит данные сессии."; +"core.error.request_encoding_failed" = "Не удалось закодировать данные: %@"; +"core.error.response_decoding_failed" = "Не удалось декодировать данные: %@"; +"core.error.request_failed" = "Запрос подключения завершился ошибкой: %@"; +"core.error.cancelled" = "Операция подключения отменена."; +"core.error.unexpected" = "Операция Core завершилась ошибкой: %@"; diff --git a/Sources/Core/Response/ConnectBehaviour.swift b/Sources/Core/Response/ConnectBehaviour.swift new file mode 100644 index 0000000..5f6eb61 --- /dev/null +++ b/Sources/Core/Response/ConnectBehaviour.swift @@ -0,0 +1,49 @@ +/// Runtime availability flags returned by `POST /connect`. +package struct ConnectBehaviour: Decodable, Equatable, Sendable { + + /// Indicates whether consultant managers are currently considered offline. + package let isOffline: Bool + + /// Indicates whether the chat widget part is available for the current site. + package let widget: Bool? + + /// Indicates whether tracker events are accepted for the current site. + package let tracker: Bool? + + /// Indicates whether Global Privacy Control is enabled for the current request. + private let gpc: Bool? + + /// Indicates whether tracker events require explicit consent. + package let consentForm: Bool? + + package init( + isOffline: Bool = false, + widget: Bool? = nil, + tracker: Bool? = nil, + gpc: Bool? = nil, + consentForm: Bool? = nil + ) { + self.isOffline = isOffline + self.widget = widget + self.tracker = tracker + self.gpc = gpc + self.consentForm = consentForm + } + + private enum CodingKeys: String, CodingKey { + case isOffline = "is_offline" + case widget + case tracker + case gpc + case consentForm = "consent_form" + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.isOffline = try container.decodeIfPresent(Bool.self, forKey: .isOffline) ?? false + self.widget = try container.decodeIfPresent(Bool.self, forKey: .widget) + self.tracker = try container.decodeIfPresent(Bool.self, forKey: .tracker) + self.gpc = try container.decodeIfPresent(Bool.self, forKey: .gpc) + self.consentForm = try container.decodeIfPresent(Bool.self, forKey: .consentForm) + } +} diff --git a/Sources/Core/Response/ConnectResponse.swift b/Sources/Core/Response/ConnectResponse.swift new file mode 100644 index 0000000..d0f8e3e --- /dev/null +++ b/Sources/Core/Response/ConnectResponse.swift @@ -0,0 +1,50 @@ +/// Credentials subset of the response returned by `POST /api/client/v1/connect`. +package struct ConnectResponse: Decodable, Equatable, Sendable { + + /// Backend server timestamp encoded as the `ts` ISO 8601 string. + package let timestamp: String? + + /// Current backend user. + package let user: ConnectedUser + + /// Current backend session. + package let session: ConnectedSession + + /// Runtime availability flags. + package let behaviour: ConnectBehaviour? + + /// Creates a connect response credentials subset. + package init( + timestamp: String? = nil, + user: ConnectedUser, + session: ConnectedSession, + behaviour: ConnectBehaviour? = nil + ) { + self.timestamp = timestamp + self.user = user + self.session = session + self.behaviour = behaviour + } + + private enum CodingKeys: String, CodingKey { + case timestamp = "ts" + case user + case session + case behaviour + } + + /// Resolved credentials for authorized requests, if both token and session id are present. + package var credentials: SessionCredentials? { + guard + let userToken = user.token, + let sessionID = session.id?.value + else { + return nil + } + + return SessionCredentials( + userToken: userToken, + sessionID: sessionID + ) + } +} diff --git a/Sources/Core/Response/ConnectedSession.swift b/Sources/Core/Response/ConnectedSession.swift new file mode 100644 index 0000000..7c93b8d --- /dev/null +++ b/Sources/Core/Response/ConnectedSession.swift @@ -0,0 +1,11 @@ +/// The session object returned by `POST /connect`. +package struct ConnectedSession: Decodable, Equatable, Sendable { + + /// Backend session identifier used as `X-Session-Id` after connect. + package let id: SessionID? + + /// Creates a connected session value. + package init(id: SessionID? = nil) { + self.id = id + } +} diff --git a/Sources/Core/Response/ConnectedUser.swift b/Sources/Core/Response/ConnectedUser.swift new file mode 100644 index 0000000..ae30d4a --- /dev/null +++ b/Sources/Core/Response/ConnectedUser.swift @@ -0,0 +1,28 @@ +/// The user object returned by `POST /connect`. +package struct ConnectedUser: Decodable, Equatable, Sendable { + + /// User token used as `X-User-Token` after connect. + package let token: String? + + /// User display name returned by the backend. + package let name: String? + + /// User email returned by the backend. + package let email: String? + + /// User phone returned by the backend. + package let phone: String? + + /// Creates a connected user value. + package init( + token: String? = nil, + name: String? = nil, + email: String? = nil, + phone: String? = nil + ) { + self.token = token + self.name = name + self.email = email + self.phone = phone + } +} diff --git a/Sources/Core/Response/SessionCredentials.swift b/Sources/Core/Response/SessionCredentials.swift new file mode 100644 index 0000000..ebf007f --- /dev/null +++ b/Sources/Core/Response/SessionCredentials.swift @@ -0,0 +1,18 @@ +/// Credentials used for authorized requests after a successful connect. +package struct SessionCredentials: Equatable, Sendable { + + /// Value for the `X-User-Token` header. + package let userToken: String + + /// Value for the `X-Session-Id` header. + package let sessionID: String + + /// Creates credentials for authorized client API requests. + package init( + userToken: String, + sessionID: String + ) { + self.userToken = userToken + self.sessionID = sessionID + } +} diff --git a/Sources/Core/Response/SessionID.swift b/Sources/Core/Response/SessionID.swift new file mode 100644 index 0000000..eac293b --- /dev/null +++ b/Sources/Core/Response/SessionID.swift @@ -0,0 +1,31 @@ +/// Backend session identifier returned by `POST /connect`. +package struct SessionID: Decodable, Equatable, Sendable { + + /// String representation of `session.id`. + package let value: String + + /// Creates a session id from a string value. + package init(value: String) { + self.value = value + } + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self) { + value = stringValue + return + } + if let intValue = try? container.decode(Int64.self) { + value = String(intValue) + return + } + + throw DecodingError.typeMismatch( + String.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected string or integer session id" + ) + ) + } +} diff --git a/Sources/Core/SDKResourceBundle.swift b/Sources/Core/SDKResourceBundle.swift new file mode 100644 index 0000000..4f2824c --- /dev/null +++ b/Sources/Core/SDKResourceBundle.swift @@ -0,0 +1,18 @@ +#if !SWIFT_PACKAGE +import Foundation + +private final class SDKResourceBundleToken {} + +extension Bundle { + static let module: Bundle = { + let moduleBundle = Bundle(for: SDKResourceBundleToken.self) + for container in [moduleBundle, Bundle.main] { + if let url = container.url(forResource: "SimlaCore", withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } + } + fatalError("Missing SimlaCore.bundle") + }() +} +#endif diff --git a/Sources/Core/Session/CrmSdkCustomerIdentity.swift b/Sources/Core/Session/CrmSdkCustomerIdentity.swift new file mode 100644 index 0000000..324737f --- /dev/null +++ b/Sources/Core/Session/CrmSdkCustomerIdentity.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Identifies an integrator's customer in a retailCRM store. +public struct CrmSdkCustomerIdentity: Equatable, Sendable { + public let customerId: Int64? + public let siteCustomerId: String? + public let siteCode: String? + + /// Creates a retailCRM customer identity for the shared SDK session. + /// + /// - Parameters: + /// - customerId: Internal retailCRM customer ID, if known. + /// - siteCustomerId: External retailCRM customer ID, if known. + /// - siteCode: CRM site code required with an external customer ID. + public init( + customerId: Int64? = nil, + siteCustomerId: String? = nil, + siteCode: String? = nil + ) { + self.customerId = customerId + self.siteCustomerId = siteCustomerId?.nilIfEmpty + self.siteCode = siteCode?.nilIfEmpty + } + + package func validated() throws -> CrmSdkCustomerIdentity { + guard customerId != nil || siteCustomerId != nil else { + throw CoreError.invalidCustomerIdentity( + CoreLocalization.idEmpty.localized + ) + } + + if let customerId, customerId <= 0 { + throw CoreError.invalidCustomerIdentity( + CoreLocalization.customerIDNotPositive.localized + ) + } + + if let siteCustomerId, siteCustomerId.unicodeScalars.count > 255 { + throw CoreError.invalidCustomerIdentity( + CoreLocalization.customerExternalIDTooLong.localized + ) + } + + if let siteCode, siteCode.unicodeScalars.count > 255 { + throw CoreError.invalidCustomerIdentity( + CoreLocalization.crmSiteCodeTooLong.localized + ) + } + + if siteCustomerId != nil && siteCode == nil { + throw CoreError.invalidCustomerIdentity( + CoreLocalization.crmSiteCodeEmpty.localized + ) + } + + return self + } +} diff --git a/Sources/Core/Session/SessionLifecycleListener.swift b/Sources/Core/Session/SessionLifecycleListener.swift new file mode 100644 index 0000000..fbe281a --- /dev/null +++ b/Sources/Core/Session/SessionLifecycleListener.swift @@ -0,0 +1,16 @@ +/// Receives shared session lifecycle changes from `SessionRuntime`. +package protocol SessionLifecycleListener: AnyObject { + func sessionDidConnect( + credentials: SessionCredentials, + pageContext: ConnectPageContext, + user: ConnectUser, + response: ConnectResponse, + customerIdentity: CrmSdkCustomerIdentity? + ) + func sessionDidDisconnect() + func sessionDidFailToConnect(_ error: CoreError) +} + +package extension SessionLifecycleListener { + func sessionDidFailToConnect(_ error: CoreError) {} +} diff --git a/Sources/Core/Session/SessionRuntime.swift b/Sources/Core/Session/SessionRuntime.swift new file mode 100644 index 0000000..7c37cd6 --- /dev/null +++ b/Sources/Core/Session/SessionRuntime.swift @@ -0,0 +1,330 @@ +import Foundation + +/// Shared low-level runtime for SDK modules that use the client API. +package actor SessionRuntime { + + /// Semantic version of the connect module bundled with this package. + nonisolated package static let version = "0.1.0" + + /// Runtime configuration used for connect requests. + nonisolated package let config: SessionConfig + nonisolated private let siteCustomerIdStore = SiteCustomerIdStore() + + nonisolated package var siteCustomerId: String? { + siteCustomerIdStore.value + } + + private let session: URLSession + private let userTokenStorage: UserTokenStorage + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private(set) package var credentials: SessionCredentials? + private(set) package var customerIdentity: CrmSdkCustomerIdentity? + + private var connectResponseData: Data? + private var connectedResponse: ConnectResponse? + private let initialPageContext: ConnectPageContext + private let initialUser: ConnectUser + private var connectTask: Task? + private var connectTaskID = UUID() + private var lastConnectionError: CoreError? + private var identityChangeTask: Task? + private var identityChangeGeneration = 0 + private var connectionGeneration = 0 + private var lifecycleListeners: [ObjectIdentifier: WeakSessionLifecycleListener] = [:] + + package init( + config: SessionConfig, + customerIdentity: CrmSdkCustomerIdentity? = nil, + pageContext: ConnectPageContext = ConnectPageContext(), + session: URLSession = NetworkSession.shared, + userTokenStorage: UserTokenStorage? = nil + ) { + self.config = config + self.customerIdentity = customerIdentity + self.initialPageContext = pageContext + self.initialUser = ConnectUser() + self.session = session + self.userTokenStorage = userTokenStorage ?? KeychainUserTokenStorage() + } + + /// Opens or resumes a backend session through `POST /api/client/v1/connect`. + @discardableResult + package func start() async throws -> SessionCredentials { + _ = try await connectionData() + guard let credentials else { + throw CoreError.missingConnectionCredentials + } + return credentials + } + + /// Opens or resumes a backend session and decodes feature-specific bootstrap data. + @discardableResult + package func connect( + responseType: Response.Type + ) async throws -> Response where Response: Decodable { + let data = try await connectionData() + return try decoder.decode(Response.self, from: data) + } + + /// Switches the shared customer identity and reconnects the common session. + package func setCustomerIdentity(_ identity: CrmSdkCustomerIdentity?) async throws { + let identity = try identity?.validated() + try await enqueueIdentityChange { + try await self.applyCustomerIdentity(identity) + } + } + + private func enqueueIdentityChange(_ operation: @escaping @Sendable () async throws -> Void) async throws { + let previousTask = identityChangeTask + identityChangeGeneration += 1 + let generation = identityChangeGeneration + let task = Task { + if let previousTask { + _ = try? await previousTask.value + } + try await operation() + } + identityChangeTask = task + + defer { + if generation == identityChangeGeneration { + identityChangeTask = nil + } + } + try await task.value + } + + /// Registers a module only when the shared session is already connected. Does not connect or wait for connection. + private func addConnectedLifecycleListener( + _ listener: SessionLifecycleListener + ) throws -> SessionCredentials { + guard let credentials, connectedResponse != nil else { + throw CoreError.missingConnectionCredentials + } + addLifecycleListener(listener) + return credentials + } + + /// Registers a module for shared session lifecycle changes. + package func addLifecycleListener(_ listener: SessionLifecycleListener) { + let key = ObjectIdentifier(listener) + guard lifecycleListeners[key]?.value == nil else { + return + } + + lifecycleListeners = lifecycleListeners.filter { $0.value.value != nil } + lifecycleListeners[key] = WeakSessionLifecycleListener(listener) + if let credentials, let connectedResponse { + listener.sessionDidConnect( + credentials: credentials, + pageContext: initialPageContext, + user: initialUser, + response: connectedResponse, + customerIdentity: customerIdentity + ) + } else if let lastConnectionError { + listener.sessionDidFailToConnect(lastConnectionError) + } + } + + private func applyCustomerIdentity(_ identity: CrmSdkCustomerIdentity?) async throws { + guard customerIdentity != identity else { + _ = try await connectionData() + return + } + + disconnect() + customerIdentity = identity + _ = try await connectionData() + } + + package func authorizedHeaders(contentType: Bool = true) throws -> [String: String] { + guard let credentials else { + throw CoreError.missingConnectionCredentials + } + var headers = siteHeaders(contentType: contentType) + headers["X-User-Token"] = credentials.userToken + headers["X-Session-Id"] = credentials.sessionID + return headers + } + + /// Returns credentials and customer identity from the same connected session. + package func authorizedRequestContext() async throws -> ( + headers: [String: String], customerIdentity: CrmSdkCustomerIdentity? + ) { + _ = try await connectionData() + return (try authorizedHeaders(), customerIdentity) + } + + nonisolated package func siteHeaders(contentType: Bool = true) -> [String: String] { + var headers = [ + "Accept": "application/json", + "X-Site-Token": config.siteToken + ] + if contentType { + headers["Content-Type"] = "application/json" + } + if let customerSiteId = siteCustomerId { + headers["X-Site-Customer-Id"] = customerSiteId + } + return headers + } + + private func connectionData() async throws -> Data { + if let connectResponseData { + return connectResponseData + } + + let generation = connectionGeneration + let task: Task + let taskID: UUID + + if let connectTask { + task = connectTask + taskID = connectTaskID + } else { + taskID = UUID() + connectTaskID = taskID + lastConnectionError = nil + let request: URLRequest + do { + customerIdentity = try customerIdentity?.validated() + siteCustomerIdStore.set(customerIdentity?.siteCustomerId) + request = try connectRequest() + } catch { + recordConnectionFailure(error) + throw error + } + let session = self.session + task = Task { + let (data, response) = try await NetworkSession.data( + for: request, in: session + ) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(statusCode) else { + throw CoreError.httpStatus( + statusCode, + CoreError.errorMessages(from: data) + ) + } + return data + } + connectTask = task + } + + do { + let data = try await task.value + guard generation == connectionGeneration, taskID == connectTaskID else { + throw CancellationError() + } + + if let connectResponseData { + return connectResponseData + } + + let response = try decoder.decode(ConnectResponse.self, from: data) + try storeConnectResponse(response) + connectResponseData = data + connectTask = nil + lastConnectionError = nil + return data + } catch { + if generation == connectionGeneration, taskID == connectTaskID { + connectTask = nil + if !(error is CancellationError) { recordConnectionFailure(error) } + } + throw error + } + } + + private func recordConnectionFailure(_ error: Error) { + let failure = CoreError.wrapping(error) + guard lastConnectionError != failure else { return } + lastConnectionError = failure + lifecycleListeners.values.compactMap(\.value).forEach { $0.sessionDidFailToConnect(failure) } + } + + private func connectRequest() throws -> URLRequest { + let persistedToken = try userTokenStorage.getUserToken( + siteToken: config.siteToken, + customerIdentity: customerIdentity + ) + let requestBody = ConnectRequest( + user: initialUser.withToken(persistedToken), + pageContext: initialPageContext + ) + + let url: URL = "connect" + .split(separator: "/") + .reduce(config.endpoints.clientAPIEndpointURL) { url, pathComponent in + url.appendingPathComponent(String(pathComponent)) + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + siteHeaders().forEach { header, value in + request.setValue(value, forHTTPHeaderField: header) + } + request.httpBody = try encoder.encode(requestBody) + return request + } + + private func storeConnectResponse(_ response: ConnectResponse) throws { + guard let credentials = response.credentials else { + throw CoreError.missingConnectionCredentials + } + + try userTokenStorage.setUserToken( + credentials.userToken, + siteToken: config.siteToken, + customerIdentity: customerIdentity + ) + self.credentials = credentials + connectedResponse = response + lastConnectionError = nil + lifecycleListeners.values.compactMap(\.value).forEach { + $0.sessionDidConnect( + credentials: credentials, + pageContext: initialPageContext, + user: initialUser, + response: response, + customerIdentity: customerIdentity + ) + } + } + + private func disconnect() { + connectionGeneration += 1 + connectTask?.cancel() + connectTask = nil + connectTaskID = UUID() + lastConnectionError = nil + connectResponseData = nil + connectedResponse = nil + credentials = nil + siteCustomerIdStore.set(nil) + lifecycleListeners.values.compactMap(\.value).forEach { + $0.sessionDidDisconnect() + } + } +} + +private final class WeakSessionLifecycleListener { + weak var value: (any SessionLifecycleListener)? + init(_ value: any SessionLifecycleListener) { self.value = value } +} + +private final class SiteCustomerIdStore: @unchecked Sendable { + private let lock = NSLock() + private var siteCustomerId: String? + + var value: String? { + lock.withLock { siteCustomerId } + } + + func set(_ id: String?) { + lock.withLock { siteCustomerId = id } + } +} diff --git a/Sources/Core/Session/UserTokenStorage.swift b/Sources/Core/Session/UserTokenStorage.swift new file mode 100644 index 0000000..efb8ebe --- /dev/null +++ b/Sources/Core/Session/UserTokenStorage.swift @@ -0,0 +1,147 @@ +import CryptoKit +import Foundation +import Security + +/// Storage for the backend user token returned by `POST /connect`. +package protocol UserTokenStorage: AnyObject { + + /// Returns the token saved for a site and customer identity. + func getUserToken( + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) throws -> String? + + /// Saves or removes the token for a site and customer identity. + func setUserToken( + _ token: String?, + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) throws +} + +/// Keychain-backed storage that isolates tokens by site and customer identity. +package final class KeychainUserTokenStorage: UserTokenStorage { + private static let service = "com.retailcrm.consultant-sdk.user-token" + + package init() {} + + package func getUserToken( + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) throws -> String? { + var query = baseQuery( + siteToken: siteToken, + customerIdentity: customerIdentity + ) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard + let data = result as? Data, + let token = String(data: data, encoding: .utf8) + else { + throw CoreError.keychainInvalidTokenData + } + return token + case errSecItemNotFound: + return nil + default: + throw CoreError.keychainUnexpectedStatus(status) + } + } + + package func setUserToken( + _ token: String?, + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) throws { + let query = baseQuery( + siteToken: siteToken, + customerIdentity: customerIdentity + ) + + guard let token else { + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CoreError.keychainUnexpectedStatus(status) + } + return + } + + let tokenData = Data(token.utf8) + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: tokenData] as CFDictionary + ) + + if updateStatus == errSecSuccess { + return + } + + guard updateStatus == errSecItemNotFound else { + throw CoreError.keychainUnexpectedStatus(updateStatus) + } + + var newItem = query + newItem[kSecValueData as String] = tokenData + newItem[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + let addStatus = SecItemAdd(newItem as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw CoreError.keychainUnexpectedStatus(addStatus) + } + } + + private func baseQuery( + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: account( + siteToken: siteToken, + customerIdentity: customerIdentity + ) + ] + } + + private func account( + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) -> String { + let components: [String] + + if let customerIdentity, let customerId = customerIdentity.customerId { + components = [ + "customer-id", + siteToken, + String(customerId), + customerIdentity.siteCode ?? "", + customerIdentity.siteCustomerId ?? "" + ] + } else if let customerIdentity { + components = [ + "customer", + siteToken, + customerIdentity.siteCode ?? "", + customerIdentity.siteCustomerId ?? "" + ] + } else { + components = ["anonymous", siteToken] + } + + let scopeData = components.reduce(into: Data()) { data, component in + data.append(contentsOf: component.utf8) + data.append(0) + } + return SHA256.hash(data: scopeData) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Sources/OnlineConsultant/Components/ConsultantApplicationLifecycle.swift b/Sources/OnlineConsultant/Components/ConsultantApplicationLifecycle.swift new file mode 100644 index 0000000..f1a1f7d --- /dev/null +++ b/Sources/OnlineConsultant/Components/ConsultantApplicationLifecycle.swift @@ -0,0 +1,70 @@ +import UIKit + +/// Tracks foreground availability across application and scene lifecycle notifications. +@MainActor +package final class ConsultantApplicationLifecycle: NSObject { + package private(set) var isInForeground: Bool + package var onForegroundChange: ((Bool) -> Void)? + + private let notificationCenter: NotificationCenter + private var foregroundSceneIDs: Set + private let sceneIdentifier: @MainActor (Any?) -> String? + + package init( + notificationCenter: NotificationCenter = .default, + initiallyInForeground: Bool? = nil, + initialForegroundSceneIDs: Set? = nil, + sceneIdentifier: @escaping @MainActor (Any?) -> String? = { ($0 as? UIScene)?.session.persistentIdentifier } + ) { + let scenes = UIApplication.shared.connectedScenes + let foregroundScenes = Set(scenes.filter { + $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive + }.map { $0.session.persistentIdentifier }) + self.notificationCenter = notificationCenter + self.foregroundSceneIDs = initialForegroundSceneIDs ?? foregroundScenes + self.isInForeground = initiallyInForeground ?? (scenes.isEmpty + ? UIApplication.shared.applicationState != .background + : !foregroundScenes.isEmpty) + self.sceneIdentifier = sceneIdentifier + super.init() + + notificationCenter.addObserver(self, selector: #selector(applicationWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(applicationWillEnterForeground), name: UIApplication.didBecomeActiveNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(applicationDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.didActivateNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(sceneDidLeaveForeground), name: UIScene.didEnterBackgroundNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(sceneDidLeaveForeground), name: UIScene.didDisconnectNotification, object: nil) + } + + deinit { + notificationCenter.removeObserver(self) + } + + @objc private func applicationWillEnterForeground() { + setForeground(true) + } + + @objc private func applicationDidEnterBackground() { + foregroundSceneIDs.removeAll() + setForeground(false) + } + + @objc private func sceneWillEnterForeground(_ notification: Notification) { + guard let id = sceneIdentifier(notification.object) else { return } + foregroundSceneIDs.insert(id) + setForeground(true) + } + + @objc private func sceneDidLeaveForeground(_ notification: Notification) { + guard let id = sceneIdentifier(notification.object) else { return } + foregroundSceneIDs.remove(id) + setForeground(!foregroundSceneIDs.isEmpty) + } + + private func setForeground(_ value: Bool) { + guard isInForeground != value else { return } + isInForeground = value + onForegroundChange?(value) + } +} diff --git a/Sources/OnlineConsultant/Components/ConsultantFileCache.swift b/Sources/OnlineConsultant/Components/ConsultantFileCache.swift new file mode 100644 index 0000000..5a6398f --- /dev/null +++ b/Sources/OnlineConsultant/Components/ConsultantFileCache.swift @@ -0,0 +1,299 @@ +import Core +import Foundation + +/// Stores attachment bytes as files and their metadata in a separate SQLite database. +/// Filesystem operations are serialized with metadata updates; reconciliation repairs interrupted work. +package final class ConsultantFileCache: @unchecked Sendable { + private let rootDirectory: URL + private let filesDirectory: URL + private let databaseURL: URL + private let fileManager: FileManager + private let now: () -> Date + private let queue = DispatchQueue(label: "ConsultantSDK.FileCache") + private var storage: ConsultantFileCacheStorage? + + package init( + rootDirectory: URL? = nil, + fileManager: FileManager = .default, + now: @escaping () -> Date = Date.init + ) { + self.fileManager = fileManager + self.now = now + let root = rootDirectory ?? fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("ConsultantSDK", isDirectory: true) + self.rootDirectory = root + filesDirectory = root.appendingPathComponent("files", isDirectory: true) + databaseURL = root.appendingPathComponent(ConsultantFileCacheStorage.databaseFileName) + queue.sync { + do { + try storageUnsafe().transaction { try reconcileUnsafe($0) } + } catch { + log(error, operation: "reconcile") + } + } + } + + /// Returns a cached file URL and updates its last access time. + package func cachedFileURL(fileID: String) -> URL? { + queue.sync { + guard let fileID = normalizedFileID(fileID) else { return nil } + do { + return try storageUnsafe().transaction { records in + guard let entry = try existingEntryUnsafe(fileID: fileID, records: records) else { return nil } + try records.touch(fileID: fileID, at: now()) + return filesDirectory.appendingPathComponent(entry.localFileName) + } + } catch { + log(error, operation: "read") + return nil + } + } + } + + /// Publishes a new file version only after its metadata has been committed. + @discardableResult + package func store( + data: Data, + fileID: String, + contentType: String? = nil, + remoteURL: URL? = nil + ) throws -> URL { + try queue.sync { + guard let fileID = normalizedFileID(fileID) else { throw ConsultantFileCacheError.emptyFileID } + let storage = try storageUnsafe() + let fileName = UUID().uuidString + ".data" + let fileURL = filesDirectory.appendingPathComponent(fileName) + var previousURL: URL? + do { + try storage.transaction { records in + let previous = try records.entry(fileID: fileID) + try fileManager.createDirectory(at: filesDirectory, withIntermediateDirectories: true) + try data.write(to: fileURL, options: .atomic) + let date = now() + try records.save(.init( + fileID: fileID, localFileName: fileName, remoteURL: remoteURL, contentType: contentType, + size: Int64(data.count), createdAt: previous?.createdAt ?? date, lastAccessedAt: date + )) + previousURL = previous.map { filesDirectory.appendingPathComponent($0.localFileName) } + } + } catch { + // The previous version is still referenced if the transaction did not commit. + do { try removeFileIfPresentUnsafe(fileURL) } catch { log(error, operation: "remove unpublished file") } + throw error + } + if let previousURL { + do { try removeFileIfPresentUnsafe(previousURL) } catch { log(error, operation: "remove superseded file") } + } + return fileURL + } + } + + package func updateRemoteURL(fileID: String, remoteURL: URL) throws { + try queue.sync { + guard let fileID = normalizedFileID(fileID) else { throw ConsultantFileCacheError.emptyFileID } + let updated = try storageUnsafe().transaction { records in + guard try existingEntryUnsafe(fileID: fileID, records: records) != nil else { return false } + try records.updateRemoteURL(fileID: fileID, remoteURL: remoteURL, at: now()) + return true + } + guard updated else { throw ConsultantFileCacheError.missingEntry(fileID: fileID) } + } + } + + package func entry(fileID: String) -> ConsultantFileCacheEntry? { + queue.sync { + guard let fileID = normalizedFileID(fileID) else { return nil } + do { + return try storageUnsafe().transaction { try existingEntryUnsafe(fileID: fileID, records: $0) } + } catch { + log(error, operation: "read metadata") + return nil + } + } + } + + package func statistics() -> ConsultantCacheStatistics { + queue.sync { + do { + return try storageUnsafe().transaction { records in + try removeMissingEntriesUnsafe(records) + return try records.statistics() + } + } catch { + log(error, operation: "read statistics") + return .init(fileCount: 0, totalDiskSizeBytes: 0) + } + } + } + + @discardableResult + package func trim(policy: OnlineConsultantCachePolicy) throws -> ConsultantCacheStatistics { + try queue.sync { + do { + return try storageUnsafe().transaction { records in + try reconcileUnsafe(records) + if let age = policy.maxFileAge, age >= 0 { + for entry in try records.entries(olderThan: now().addingTimeInterval(-age)) { + try removeEntryUnsafe(entry, records: records) + } + } + if let maxSize = policy.maxDiskSizeBytes, maxSize >= 0 { + var size = try records.statistics().totalDiskSizeBytes + if size > maxSize { + for entry in try records.oldestEntries() { + guard size > maxSize else { break } + try removeEntryUnsafe(entry, records: records) + size -= entry.size + } + } + } + return try records.statistics() + } + } catch { + repairMissingEntriesUnsafe() + throw error + } + } + } + + /// Clears attachments and metadata without unlinking an open SQLite database. + package func removeAll() throws { + try queue.sync { + do { + try storageUnsafe().transaction { records in + try records.removeAll() + try removeFileIfPresentUnsafe(filesDirectory) + try removeFileIfPresentUnsafe(rootDirectory.appendingPathComponent("manifest.json")) + } + } catch { + repairMissingEntriesUnsafe() + throw error + } + } + } + + private func storageUnsafe() throws -> ConsultantFileCacheStorage { + if let storage, fileManager.fileExists(atPath: databaseURL.path) { return storage } + // The system may purge the Caches directory while this instance is still alive. + storage = nil + let opened = try ConsultantFileCacheStorage(url: databaseURL) + storage = opened + return opened + } + + private func existingEntryUnsafe( + fileID: String, records: ConsultantFileCacheStorage.Records + ) throws -> ConsultantFileCacheEntry? { + guard let entry = try records.entry(fileID: fileID) else { return nil } + guard fileManager.fileExists(atPath: filesDirectory.appendingPathComponent(entry.localFileName).path) else { + try records.remove(fileID: fileID) + return nil + } + return entry + } + + private func removeEntryUnsafe(_ entry: ConsultantFileCacheEntry, records: ConsultantFileCacheStorage.Records) throws { + // A database error leaves the file untouched. A filesystem error rolls back the metadata deletion. + try records.remove(fileID: entry.fileID) + try removeFileIfPresentUnsafe(filesDirectory.appendingPathComponent(entry.localFileName)) + } + + private func removeMissingEntriesUnsafe(_ records: ConsultantFileCacheStorage.Records) throws { + for entry in try records.entries() { + if !fileManager.fileExists(atPath: filesDirectory.appendingPathComponent(entry.localFileName).path) { + try records.remove(fileID: entry.fileID) + } + } + } + + private func reconcileUnsafe(_ records: ConsultantFileCacheStorage.Records) throws { + try removeMissingEntriesUnsafe(records) + let referenced = Set(try records.entries().map(\.localFileName)) + if fileManager.fileExists(atPath: filesDirectory.path) { + for url in try fileManager.contentsOfDirectory(at: filesDirectory, includingPropertiesForKeys: nil) { + if !referenced.contains(url.lastPathComponent) { try removeFileIfPresentUnsafe(url) } + } + } + // The pre-release JSON format is discarded rather than migrated. + try removeFileIfPresentUnsafe(rootDirectory.appendingPathComponent("manifest.json")) + } + + private func repairMissingEntriesUnsafe() { + do { + try storageUnsafe().transaction { try removeMissingEntriesUnsafe($0) } + } catch { + log(error, operation: "repair interrupted cleanup") + } + } + + private func removeFileIfPresentUnsafe(_ url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { return } + do { + try fileManager.removeItem(at: url) + } catch let error as CocoaError where error.code == .fileNoSuchFile || error.code == .fileReadNoSuchFile { + // Another cache instance may have already removed a superseded file. + } + } + + private func normalizedFileID(_ fileID: String) -> String? { + let trimmed = fileID.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func log(_ error: Error, operation: String) { + NSLog("RetailCRM consultant file cache could not %@: %@", operation, error.localizedDescription) + } +} + +/// Errors produced by the local file cache. +package enum ConsultantFileCacheError: Error, Equatable, LocalizedError { + /// The provided file id is empty after trimming whitespace. + case emptyFileID + /// The cache does not contain an entry for the requested file id. + case missingEntry(fileID: String) + + package var errorDescription: String? { + switch self { + case .emptyFileID: + return ConsultantLocalization.fileCacheErrorEmptyFileID.localized + case .missingEntry(let fileID): + return ConsultantLocalization.fileCacheErrorMissingEntry.format(fileID) + } + } +} + +/// Metadata describing a locally cached consultant file. +package struct ConsultantFileCacheEntry: Codable, Equatable { + /// Backend file id used as the cache key. + package let fileID: String + /// File name used inside the SDK cache directory. + package var localFileName: String + /// Last known remote URL for this file, if it was resolved from the backend. + package var remoteURL: URL? + /// MIME content type, if known. + package var contentType: String? + /// Stored file size in bytes. + package var size: Int64 + /// Date when the cache entry was first created. + package var createdAt: Date + /// Date when the cache entry was last read or updated. + package var lastAccessedAt: Date + + package init( + fileID: String, + localFileName: String, + remoteURL: URL?, + contentType: String?, + size: Int64, + createdAt: Date, + lastAccessedAt: Date + ) { + self.fileID = fileID + self.localFileName = localFileName + self.remoteURL = remoteURL + self.contentType = contentType + self.size = size + self.createdAt = createdAt + self.lastAccessedAt = lastAccessedAt + } +} diff --git a/Sources/OnlineConsultant/Components/ConsultantFileCacheStorage.swift b/Sources/OnlineConsultant/Components/ConsultantFileCacheStorage.swift new file mode 100644 index 0000000..5427070 --- /dev/null +++ b/Sources/OnlineConsultant/Components/ConsultantFileCacheStorage.swift @@ -0,0 +1,117 @@ +import Core +import Foundation +import SQLiteStorage + +/// Metadata only; attachment bytes remain in the adjacent files directory. +package final class ConsultantFileCacheStorage { + package static let databaseFileName = "crm_consultant_cache.sqlite" + private let database: SQLiteDatabase + + package init(url: URL) throws { + database = try SQLiteDatabase(url: url) + try database.migrate([""" + CREATE TABLE cache_files ( + file_id TEXT PRIMARY KEY NOT NULL, + local_file_name TEXT UNIQUE NOT NULL, + remote_url TEXT, + content_type TEXT, + size INTEGER NOT NULL CHECK (size >= 0), + created_at REAL NOT NULL, + last_accessed_at REAL NOT NULL + ); + CREATE INDEX cache_files_last_accessed ON cache_files(last_accessed_at, created_at, file_id); + """]) + } + + /// The write transaction also coordinates filesystem work across cache instances. + package func transaction(_ operation: (Records) throws -> T) throws -> T { + try database.transaction { try operation(Records(connection: $0)) } + } + + package struct Records { + private let connection: SQLiteConnection + private static let columns = "file_id, local_file_name, remote_url, content_type, size, created_at, last_accessed_at" + + package func entry(fileID: String) throws -> ConsultantFileCacheEntry? { + try connection.query("SELECT \(Self.columns) FROM cache_files WHERE file_id = ?;", bindings: [.text(fileID)]) + .first.map(Self.decode) + } + + package func entries() throws -> [ConsultantFileCacheEntry] { + try connection.query("SELECT \(Self.columns) FROM cache_files;").map(Self.decode) + } + + package func entries(olderThan date: Date) throws -> [ConsultantFileCacheEntry] { + try connection.query( + "SELECT \(Self.columns) FROM cache_files WHERE last_accessed_at < ?;", + bindings: [.real(date.timeIntervalSince1970)] + ).map(Self.decode) + } + + package func oldestEntries() throws -> [ConsultantFileCacheEntry] { + try connection.query( + "SELECT \(Self.columns) FROM cache_files ORDER BY last_accessed_at, created_at, file_id;" + ).map(Self.decode) + } + + package func save(_ entry: ConsultantFileCacheEntry) throws { + try connection.execute(""" + INSERT INTO cache_files (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(file_id) DO UPDATE SET + local_file_name = excluded.local_file_name, remote_url = excluded.remote_url, + content_type = excluded.content_type, size = excluded.size, + created_at = excluded.created_at, last_accessed_at = excluded.last_accessed_at; + """, bindings: [ + .text(entry.fileID), .text(entry.localFileName), + entry.remoteURL.map { .text($0.absoluteString) } ?? .null, + entry.contentType.map(SQLiteValue.text) ?? .null, .integer(entry.size), + .real(entry.createdAt.timeIntervalSince1970), .real(entry.lastAccessedAt.timeIntervalSince1970) + ]) + } + + package func touch(fileID: String, at date: Date) throws { + try connection.execute( + "UPDATE cache_files SET last_accessed_at = ? WHERE file_id = ?;", + bindings: [.real(date.timeIntervalSince1970), .text(fileID)] + ) + } + + package func updateRemoteURL(fileID: String, remoteURL: URL, at date: Date) throws { + try connection.execute( + "UPDATE cache_files SET remote_url = ?, last_accessed_at = ? WHERE file_id = ?;", + bindings: [.text(remoteURL.absoluteString), .real(date.timeIntervalSince1970), .text(fileID)] + ) + } + + package func remove(fileID: String) throws { + try connection.execute("DELETE FROM cache_files WHERE file_id = ?;", bindings: [.text(fileID)]) + } + + package func removeAll() throws { + try connection.execute("DELETE FROM cache_files;") + } + + package func statistics() throws -> ConsultantCacheStatistics { + let row = try connection.query("SELECT COUNT(*), COALESCE(SUM(size), 0) FROM cache_files;")[0] + return .init(fileCount: Int(row[0].integer ?? 0), totalDiskSizeBytes: row[1].integer ?? 0) + } + + private static func decode(_ row: [SQLiteValue]) throws -> ConsultantFileCacheEntry { + guard let fileID = row[0].string, let fileName = row[1].string, + !fileName.isEmpty, fileName != ".", fileName != "..", !fileName.contains("/"), + let size = row[4].integer, let createdAt = row[5].double, + let lastAccessedAt = row[6].double else { + throw SQLiteStorageError(message: "Invalid consultant file cache record.") + } + return .init( + fileID: fileID, localFileName: fileName, remoteURL: row[2].string.flatMap(URL.init(string:)), + contentType: row[3].string, size: size, createdAt: Date(timeIntervalSince1970: createdAt), + lastAccessedAt: Date(timeIntervalSince1970: lastAccessedAt) + ) + } + + package init(connection: SQLiteConnection) { + self.connection = connection + } + } +} diff --git a/Sources/OnlineConsultant/Components/ConsultantLocalization.swift b/Sources/OnlineConsultant/Components/ConsultantLocalization.swift new file mode 100644 index 0000000..bfe0c68 --- /dev/null +++ b/Sources/OnlineConsultant/Components/ConsultantLocalization.swift @@ -0,0 +1,103 @@ +import Foundation + +package enum ConsultantLocalization: String { + + case attachmentActionCamera = "attachment.action.camera" + case attachmentActionCancel = "attachment.action.cancel" + case attachmentActionPhotoOrVideo = "attachment.action.photo_or_video" + case attachmentErrorCameraMissingUsageDescription = "attachment.error.camera_missing_usage_description" + case attachmentErrorCameraUnavailable = "attachment.error.camera_unavailable" + case attachmentErrorEmptyImageData = "attachment.error.empty_image_data" + case attachmentErrorFileTooLarge = "attachment.error.file_too_large" + case attachmentErrorImageTooLarge = "attachment.error.image_too_large" + case attachmentErrorOperationFailed = "attachment.error.operation_failed" + case attachmentErrorRetryUploadFailed = "attachment.error.retry_upload_failed" + case attachmentErrorTooManyAttachments = "attachment.error.too_many_attachments" + case attachmentFileExtensionFallback = "attachment.file_extension.fallback" + case attachmentStatusLoading = "attachment.status.loading" + + case commonError = "common.error" + case commonFile = "common.file" + case commonOff = "common.off" + case commonOk = "common.ok" + case commonOn = "common.on" + case commonRetry = "common.retry" + case commonUnavailable = "common.unavailable" + + case contactFormDefaultText = "contact_form.default_text" + case contactFormFieldComment = "contact_form.field.comment" + case contactFormFieldEmail = "contact_form.field.email" + case contactFormFieldName = "contact_form.field.name" + case contactFormFieldPhone = "contact_form.field.phone" + case contactFormMarketingConsent = "contact_form.marketing_consent" + case contactFormPersonalDataConsent = "contact_form.personal_data_consent" + case contactFormPersonalDataLinkText = "contact_form.personal_data_link_text" + case contactFormSubmit = "contact_form.submit" + case contactFormSubmitted = "contact_form.submitted" + + case clientErrorHTTPStatus = "client.error.http_status" + case clientErrorMissingCredentials = "client.error.missing_credentials" + case clientErrorRequestFailed = "client.error.request_failed" + + case controllerErrorInputAttachmentRemoved = "controller.error.input_attachment_removed" + case controllerErrorInputAttachmentsNotUploaded = "controller.error.input_attachments_not_uploaded" + case controllerErrorMissingCredentials = "controller.error.missing_credentials" + case controllerErrorMissingUploadedFileID = "controller.error.missing_uploaded_file_id" + case controllerErrorNotConnected = "controller.error.not_connected" + case controllerErrorUnavailable = "controller.error.unavailable" + + case footerMadeIn = "footer.made_in" + + case fileCacheErrorEmptyFileID = "file_cache.error.empty_file_id" + case fileCacheErrorMissingEntry = "file_cache.error.missing_entry" + case fileCacheErrorOperationFailed = "file_cache.error.operation_failed" + case unexpectedError = "consultant.error.unexpected" + + case messageActionCopy = "message.action.copy" + case messageActionRetry = "message.action.retry" + case messageAgreementFirst = "message.agreement.first" + case messageAgreementLink = "message.agreement.link" + case messageAgreementThird = "message.agreement.third" + case messageAuthorBotAssistant = "message.author.bot_assistant" + case messageAuthorConsultant = "message.author.consultant" + case messageAuthorConsultantInitial = "message.author.consultant_initial" + case messageAuthorVisitor = "message.author.visitor" + case messageDateToday = "message.date.today" + case messageDateYesterday = "message.date.yesterday" + case messageDateFormat = "message.date.format" + case messageDateYearFormat = "message.date.year_format" + case messageInputPlaceholder = "message.input.placeholder" + case messageQuoteAuthorYou = "message.quote.author_you" + case messageQuotePlaceholder = "message.quote.placeholder" + case messageQuoteUnavailable = "message.quote.unavailable" + case messageQuotePhoto = "message.quote.photo" + case messageItemImage = "message.item.image" + case messageItemProduct = "message.item.product" + + case offlineDefaultText = "offline.default_text" + case offlineFormDefaultResultText = "offline.form_default_result_text" + + case orderFieldAddress = "order.field.address" + case orderFieldComment = "order.field.comment" + case orderFieldDelivery = "order.field.delivery" + case orderFieldDeliveryCost = "order.field.delivery_cost" + case orderFieldDiscount = "order.field.discount" + case orderFieldPayment = "order.field.payment" + case orderFieldTotal = "order.field.total" + case orderLabelNumber = "order.label.number" + + case productQuantityAvailable = "product.quantity.available" + case productQuantityUnavailable = "product.quantity.unavailable" + + package var localized: String { + NSLocalizedString( + rawValue, + bundle: .module, + comment: "" + ) + } + + package func format(_ arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} diff --git a/Sources/OnlineConsultant/Components/ConsultantNetworkEngine.swift b/Sources/OnlineConsultant/Components/ConsultantNetworkEngine.swift new file mode 100644 index 0000000..c127890 --- /dev/null +++ b/Sources/OnlineConsultant/Components/ConsultantNetworkEngine.swift @@ -0,0 +1,165 @@ +import Core +import Foundation + +/// Component responsible for the SDK HTTP transport. +/// +/// It defines request/response DTOs used by the client layer and provides the +/// URLSession-backed implementation for JSON requests and raw file uploads. +/// Executes HTTP requests used by the online consultant client. +package protocol ConsultantNetworkEngine: AnyObject { + + /// Sends a GET request and returns the raw HTTP response. + func getJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse + /// Sends a JSON POST request and returns the raw HTTP response. + func postJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse + /// Sends a raw POST request with an explicit content type. + func postRaw(_ request: ConsultantRawHTTPRequest) async throws -> ConsultantHTTPResponse +} + +/// Describes a JSON HTTP request sent by the SDK. +package struct ConsultantHTTPRequest: Equatable, Sendable { + + /// Absolute endpoint URL. + package let url: URL + /// HTTP headers applied to the request. + package let headers: [String: String] + /// Optional request body. Usually contains encoded JSON for POST requests. + package let body: Data? + + /// Creates a JSON HTTP request descriptor. + package init( + url: URL, + headers: [String: String] = [:], + body: Data? = nil + ) { + self.url = url + self.headers = headers + self.body = body + } +} + +/// Describes a raw HTTP request sent by the SDK, usually for file uploads. +package struct ConsultantRawHTTPRequest: Equatable, Sendable { + + /// Absolute endpoint URL. + package let url: URL + /// HTTP headers applied to the request. + package let headers: [String: String] + /// Raw request body. + package let body: Data + /// MIME content type for the raw body. + package let contentType: String + + /// Creates a raw HTTP request descriptor. + package init( + url: URL, + headers: [String: String] = [:], + body: Data, + contentType: String + ) { + self.url = url + self.headers = headers + self.body = body + self.contentType = contentType + } +} + +/// Raw HTTP response returned by a network engine. +package struct ConsultantHTTPResponse: Equatable, Sendable { + + /// Numeric HTTP status code. + package let statusCode: Int + /// Raw response body. + package let body: Data + + /// Indicates whether the status code is in the 2xx success range. + package var isSuccessful: Bool { + (200..<300).contains(statusCode) + } + + /// Creates an HTTP response descriptor. + package init( + statusCode: Int, + body: Data + ) { + self.statusCode = statusCode + self.body = body + } +} + +/// URLSession-backed implementation of the HTTP network engine. +package final class URLSessionConsultantNetworkEngine: ConsultantNetworkEngine { + + private let session: URLSession + + /// Creates an engine backed by the SDK's shared URL session. + package convenience init() { + self.init(session: NetworkSession.shared) + } + + /// Creates an engine with an externally provided URLSession. + package init(session: URLSession) { + self.session = session + } + + /// Sends a GET request through URLSession. + package func getJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + try await execute( + url: request.url, + method: "GET", + headers: request.headers, + body: request.body + ) + } + + /// Sends a JSON POST request through URLSession. + package func postJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + try await execute( + url: request.url, + method: "POST", + headers: request.headers, + body: request.body + ) + } + + /// Sends a raw POST request through URLSession. + package func postRaw(_ request: ConsultantRawHTTPRequest) async throws -> ConsultantHTTPResponse { + try await execute( + url: request.url, + method: "POST", + headers: request.headers, + body: request.body, + contentType: request.contentType + ) + } + + private func execute( + url: URL, + method: String, + headers: [String: String], + body: Data?, + contentType: String? = nil + ) async throws -> ConsultantHTTPResponse { + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = method + urlRequest.httpBody = body + + if let contentType { + urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + + headers.forEach { key, value in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let (data, response) = try await NetworkSession.data( + for: urlRequest, in: session + ) + let httpResponse = response as? HTTPURLResponse + + return ConsultantHTTPResponse( + statusCode: httpResponse?.statusCode ?? 0, + body: data + ) + } +} diff --git a/Sources/OnlineConsultant/Components/ConsultantWebSocketEngine.swift b/Sources/OnlineConsultant/Components/ConsultantWebSocketEngine.swift new file mode 100644 index 0000000..fa1552b --- /dev/null +++ b/Sources/OnlineConsultant/Components/ConsultantWebSocketEngine.swift @@ -0,0 +1,121 @@ +import Core +import Foundation + +/// Component responsible for the SDK realtime WebSocket transport. +/// +/// It builds WebSocket handshake requests, receives backend push payloads, and +/// exposes only message data that the client can decode into realtime events. +/// Opens the realtime WebSocket connection used by the online consultant client. +package protocol ConsultantWebSocketEngine: AnyObject { + + /// Opens a WebSocket request and emits received backend message payloads. + func open(_ request: ConsultantWebSocketRequest) -> AsyncThrowingStream +} + +/// Describes a WebSocket connection request. +package struct ConsultantWebSocketRequest: Equatable, Sendable { + + /// WebSocket endpoint URL with query parameters. + package let url: URL + /// HTTP headers applied during the WebSocket handshake. + package let headers: [String: String] + + /// Creates a WebSocket request descriptor. + package init( + url: URL, + headers: [String: String] = [:] + ) { + self.url = url + self.headers = headers + } +} + +/// Event emitted by a WebSocket engine. +package enum ConsultantWebSocketEvent: Equatable, Sendable { + + /// Text message received from the backend. + case event(rawText: String) + /// Binary message received from the backend. + case binary(Data) +} + +/// URLSession-backed implementation of the consultant WebSocket engine. +package final class URLSessionConsultantWebSocketEngine: ConsultantWebSocketEngine { + + private let session: URLSession + private let connectTimeout: TimeInterval + + /// Creates a WebSocket engine with an opening timeout applied to the handshake request. + package init( + session: URLSession = NetworkSession.shared, + connectTimeout: TimeInterval = 30 + ) { + self.session = session + self.connectTimeout = connectTimeout + } + + /// Opens the WebSocket and returns an async stream of received events. + package func open(_ request: ConsultantWebSocketRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + var preparedRequest = URLRequest(url: request.url) + preparedRequest.timeoutInterval = connectTimeout + + request.headers.forEach { key, value in + preparedRequest.setValue(value, forHTTPHeaderField: key) + } + let urlRequest = preparedRequest + + let task = session.webSocketTask(with: urlRequest) + task.resume() + + let receiveTask = Task { + do { + while !Task.isCancelled { + let message = try await receiveMessage(from: task) + continuation.yield(event(from: message)) + } + } catch { + guard !Task.isCancelled else { return } + + if task.closeCode != .invalid { + continuation.finish() + return + } + + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + receiveTask.cancel() + task.cancel(with: .goingAway, reason: nil) + } + } + } + + private func receiveMessage( + from task: URLSessionWebSocketTask + ) async throws -> URLSessionWebSocketTask.Message { + try await withCheckedThrowingContinuation { continuation in + task.receive { result in + switch result { + case .success(let message): + continuation.resume(returning: message) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func event(from message: URLSessionWebSocketTask.Message) -> ConsultantWebSocketEvent { + switch message { + case .string(let text): + return .event(rawText: text) + case .data(let data): + return .binary(data) + @unknown default: + return .event(rawText: "") + } + } +} diff --git a/Sources/OnlineConsultant/Components/ContactFormPresentation.swift b/Sources/OnlineConsultant/Components/ContactFormPresentation.swift new file mode 100644 index 0000000..0e96846 --- /dev/null +++ b/Sources/OnlineConsultant/Components/ContactFormPresentation.swift @@ -0,0 +1,70 @@ +/// Component responsible for resolving contact form presentation state. +/// +/// It combines backend consultant configuration, current messages, offline state, +/// and known visitor contact data to decide whether the form is hidden, visible, +/// or waiting until the visitor sends the first message. +/// Describes how the SDK should present the contact form in the current conversation state. +package enum ContactFormPresentation: Equatable, Sendable { + + /// The contact form should not be shown. + case hidden + /// The contact form should be shown immediately. + case visible(ContactForm) + /// The contact form should be shown after the visitor sends the first message. + case waitingForFirstMessage(ContactForm) + + /// Builds the presentation state from backend configuration, loaded messages, and visitor data. + package static func make( + response: ConsultantConnectResponse, + messages: [ConsultantMessage], + isOffline: Bool, + user: ContactFormUser + ) -> ContactFormPresentation { + guard + !isOffline, + response.config?.useContactForm == true, + let fields = response.config?.contactForm?.fields, + !fields.isEmpty + else { + return .hidden + } + + let requiresContactForm = fields.contains { field, config in + switch field { + case .personalData: + return false + case .marketingConsent: + return config.required + default: + return config.required && !user.hasFilledValue(for: field) + } + } + + guard requiresContactForm else { + return .hidden + } + + let display = response.config?.contactFormDisplay ?? .afterFirstMessage + let contactForm = ContactForm( + text: response.config?.contactFormText, + display: display, + fields: fields, + prefill: ContactFormPrefill( + name: user.name, + email: user.email, + phone: user.phone + ) + ) + + switch display { + case .atBeginningDialogue: + return messages.contains { !$0.isVirtualSupportMessage } + ? .hidden + : .visible(contactForm) + case .afterFirstMessage: + return messages.containsVisitorMessage + ? .visible(contactForm) + : .waitingForFirstMessage(contactForm) + } + } +} diff --git a/Sources/OnlineConsultant/Components/RemoteFileCacheLoader.swift b/Sources/OnlineConsultant/Components/RemoteFileCacheLoader.swift new file mode 100644 index 0000000..5ad2bc7 --- /dev/null +++ b/Sources/OnlineConsultant/Components/RemoteFileCacheLoader.swift @@ -0,0 +1,84 @@ +import Core +import Foundation + +/// Component responsible for downloading remote message files into the local cache. +/// +/// It loads remote file bytes, validates successful HTTP responses, preserves +/// response content type when available, and delegates disk persistence to +/// `ConsultantFileCache`. +/// Loads remote file content and stores it in the local SDK file cache. +package protocol ConsultantRemoteFileCacheLoading: AnyObject { + /// Downloads a remote file and returns the local cached file URL. + func cacheRemoteFile( + fileID: String, + remoteURL: URL + ) async throws -> URL +} + +/// Abstracts remote data loading for file-cache downloads. +package protocol ConsultantRemoteFileDataLoading: AnyObject { + /// Loads data for a URL request. + func loadData(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: ConsultantRemoteFileDataLoading { + + /// Loads data using URLSession dataTask. + package func loadData(for request: URLRequest) async throws -> (Data, URLResponse) { + let (data, response) = try await NetworkSession.data( + for: request, in: self + ) + return (data, response) + } +} + +/// Default remote file cache loader used by the controller. +package final class RemoteFileCacheLoader: ConsultantRemoteFileCacheLoading { + + private let fileCache: ConsultantFileCache + private let dataLoader: ConsultantRemoteFileDataLoading + + /// Creates a loader with a local file cache and remote data loader. + package init( + fileCache: ConsultantFileCache, + dataLoader: ConsultantRemoteFileDataLoading = NetworkSession.shared + ) { + self.fileCache = fileCache + self.dataLoader = dataLoader + } + + /// Downloads a remote file, validates the HTTP response, and stores the bytes in the local cache. + package func cacheRemoteFile( + fileID: String, + remoteURL: URL + ) async throws -> URL { + let (data, response) = try await dataLoader.loadData(for: URLRequest(url: remoteURL)) + + if let httpResponse = response as? HTTPURLResponse, + !httpResponse.isSuccessful + { + throw ConsultantClientError.httpStatus(httpResponse.statusCode, CoreError.errorMessages(from: data)) + } + + return try fileCache.store( + data: data, + fileID: fileID, + contentType: response.contentType, + remoteURL: remoteURL + ) + } +} + +private extension HTTPURLResponse { + + var isSuccessful: Bool { + (200..<300).contains(statusCode) + } +} + +private extension URLResponse { + + var contentType: String? { + (self as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type") ?? mimeType + } +} diff --git a/Sources/OnlineConsultant/ConsultantClient.swift b/Sources/OnlineConsultant/ConsultantClient.swift new file mode 100644 index 0000000..8fd6821 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantClient.swift @@ -0,0 +1,531 @@ +import Core +import Foundation + +/// Internal client that maps SDK operations to backend HTTP endpoints and realtime WebSocket events. +package final class OnlineConsultantClient { + + /// Runtime configuration used to build backend requests. + package let config: OnlineConsultantConfig + + private let sessionRuntime: SessionRuntime + private let networkEngine: ConsultantNetworkEngine + private let webSocketEngine: ConsultantWebSocketEngine + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Page size used when loading older messages. + package var messagesPageSize: Int { + max(1, min(config.messagesPageSize, 1_000)) + } + + /// Maximum number of messages loaded during initial connection bootstrap. + package var initialMessagesLimit: Int { + max(0, min(config.initialMessagesLimit, 1_000)) + } + + /// Creates a client with injectable networking and WebSocket dependencies. + package init( + config: OnlineConsultantConfig, + sessionRuntime: SessionRuntime, + networkEngine: ConsultantNetworkEngine? = nil, + webSocketEngine: ConsultantWebSocketEngine? = nil + ) { + self.config = config + self.sessionRuntime = sessionRuntime + self.networkEngine = networkEngine ?? URLSessionConsultantNetworkEngine() + self.webSocketEngine = webSocketEngine ?? URLSessionConsultantWebSocketEngine( + connectTimeout: config.connectTimeout + ) + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + } +} + +// MARK: - Connection + +package extension OnlineConsultantClient { + + /// Whether the shared session has no customer identity. + func isAnonymousSession() async -> Bool { + await sessionRuntime.customerIdentity == nil + } + + /// Opens or resumes a consultant session through the backend connect endpoint. + func connect() async throws -> ConsultantConnectResponse { + do { + return try await sessionRuntime.connect( + responseType: ConsultantConnectResponse.self + ) + } catch { + throw consultantClientError(from: error) + } + } + + private func consultantClientError(from error: Error) -> ConsultantClientError { + if let error = error as? ConsultantClientError { + return error + } + + if let error = error as? CoreError { + switch error { + case .invalidCustomerIdentity(let message): + return .requestFailed(message) + case .missingConnectionCredentials: + return .missingConnectionCredentials + case .httpStatus(let statusCode, let messages): + return .httpStatus(statusCode, messages) + default: + return .requestFailed(error.localizedDescription) + } + } + + return .requestFailed(error.localizedDescription) + } +} + +// MARK: - Realtime + +package extension OnlineConsultantClient { + + /// Opens a WebSocket stream and decodes backend realtime events. + func subscribeRealtimeEvents( + session: String, + userToken: String, + updatedAt: String? = nil + ) -> AsyncThrowingStream { + let webSocketStream = webSocketEngine.open( + ConsultantWebSocketRequest( + url: webSocketURL( + session: session, + userToken: userToken, + updatedAt: updatedAt + ), + headers: [ + "X-User-Token": userToken, + "X-Session-Id": session + ] + ) + ) + + return AsyncThrowingStream { continuation in + let task = Task { + // Deduplication is disabled while duplicate WebSocket delivery is investigated. + // var lastRealtimeEvent: ConsultantRealtimeEvent? + + do { + for try await event in webSocketStream { + guard !Task.isCancelled else { return } + + if let realtimeEvent = try self.decodeRealtimeEvent(from: event) { + continuation.yield(realtimeEvent) + } + } + + continuation.finish() + } catch { + guard !Task.isCancelled else { return } + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } +} + +// MARK: - Messages + +package extension OnlineConsultantClient { + + /// Sends a visitor text message to the backend. + func sendMessage( + _ text: String, + session: String, + userToken: String + ) async throws -> ConsultantSentMessage { + try await sendMessage( + text: text, + type: .text, + items: [], + session: session, + userToken: userToken + ) + } + + /// Sends a message body to the backend. + func sendMessage( + text: String?, + type: MessageType, + items: [ConsultantSendMessageItem], + session: String, + userToken: String + ) async throws -> ConsultantSentMessage { + let response = try await networkEngine.postJSON( + ConsultantHTTPRequest( + url: endpointURL("messages"), + headers: authorizedHeaders(session: session, userToken: userToken), + body: try encoder.encode( + ConsultantSendMessageRequest( + message: ConsultantSendMessageBody( + type: type, + text: text, + items: items.isEmpty ? nil : items + ) + ) + ) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + + return try decode(ConsultantSendMessageResponse.self, from: response).message + } + + /// Loads a page of messages for the current session. + func loadMessages( + session: String, + userToken: String, + limit: Int? = nil, + offset: Int = 0 + ) async throws -> [ConsultantMessage] { + let response = try await networkEngine.getJSON( + ConsultantHTTPRequest( + url: messagesURL( + limit: limit ?? messagesPageSize, + offset: offset + ), + headers: authorizedHeaders(session: session, userToken: userToken) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + + return try decode(ConsultantMessagesResponse.self, from: response).messages + } + + /// Marks incoming messages up to the provided backend message id as read for the current user. + func markMessageRead( + messageID: String, + session: String, + userToken: String + ) async throws { + let response = try await networkEngine.postJSON( + ConsultantHTTPRequest( + url: messageReadURL(messageID: messageID), + headers: authorizedHeaders(session: session, userToken: userToken) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + } +} + +// MARK: - Contact Forms + +package extension OnlineConsultantClient { + + /// Submits contact-form data for the current session or a site-action contact form. + func sendContactForm( + _ data: ContactFormSubmitData, + session: String, + userToken: String, + actionID: String? = nil + ) async throws { + let response = try await networkEngine.postJSON( + ConsultantHTTPRequest( + url: contactFormURL(actionID: actionID), + headers: authorizedHeaders(session: session, userToken: userToken), + body: try encoder.encode(ConsultantContactFormRequest(data: data)) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + } + + /// Submits offline-form data for the current session. + func sendOfflineForm( + _ data: ContactFormSubmitData, + session: String, + userToken: String + ) async throws { + let response = try await networkEngine.postJSON( + ConsultantHTTPRequest( + url: endpointURL("offline/form"), + headers: authorizedHeaders(session: session, userToken: userToken), + body: try encoder.encode(ConsultantContactFormRequest(data: data)) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + } +} + +// MARK: - Actions + +package extension OnlineConsultantClient { + + /// Executes a site action and returns its backend execution result. + func executeAction( + actionID: String, + session: String, + userToken: String + ) async throws -> ConsultantActionExecuteResponse { + let response = try await networkEngine.postJSON( + ConsultantHTTPRequest( + url: actionExecuteURL(actionID: actionID), + headers: authorizedHeaders(session: session, userToken: userToken) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + + return try decode(ConsultantActionExecuteResponse.self, from: response) + } + + /// Reports that a site action was shown to the visitor. + func showAction( + actionID: String, + session: String, + userToken: String + ) async throws { + let response = try await networkEngine.postJSON( + ConsultantHTTPRequest( + url: actionShowURL(actionID: actionID), + headers: authorizedHeaders(session: session, userToken: userToken) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + } +} + +// MARK: - Files + +package extension OnlineConsultantClient { + + /// Loads a temporary URL for a backend file id. + func loadFileLink( + fileID: String, + session: String, + userToken: String + ) async throws -> ConsultantFileLinkResponse { + let response = try await networkEngine.getJSON( + ConsultantHTTPRequest( + url: fileLinkURL(fileID: fileID), + headers: authorizedHeaders(session: session, userToken: userToken) + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + + return try decode(ConsultantFileLinkResponse.self, from: response) + } + + /// Uploads raw file data to the backend. + func uploadFile( + data: Data, + contentType: String = "application/octet-stream", + session: String, + userToken: String + ) async throws -> ConsultantFileUploadResponse { + let response = try await networkEngine.postRaw( + ConsultantRawHTTPRequest( + url: endpointURL("files"), + headers: rawAuthorizedHeaders(session: session, userToken: userToken), + body: data, + contentType: contentType + ) + ) + + guard response.isSuccessful else { + throw ConsultantClientError.fromHTTP(statusCode: response.statusCode, body: response.body) + } + + return try decode(ConsultantFileUploadResponse.self, from: response) + } +} + +// MARK: - Helpers + +private extension OnlineConsultantClient { + + func decode( + _ type: Value.Type, + from response: ConsultantHTTPResponse + ) throws -> Value { + return try decoder.decode(type, from: response.body) + } + + func endpointURL(_ path: String) -> URL { + path + .split(separator: "/") + .reduce(sessionRuntime.config.endpoints.clientAPIEndpointURL) { url, pathComponent in + url.appendingPathComponent(String(pathComponent)) + } + } + + func decodeRealtimeEvent( + from webSocketEvent: ConsultantWebSocketEvent + ) throws -> ConsultantRealtimeEvent? { + switch webSocketEvent { + case .event(let rawText): + return try ConsultantRealtimeEvent(rawText: rawText) + case .binary(let data): + return try decoder.decode(ConsultantRealtimeEvent.self, from: data) + } + } + + func authorizedHeaders(session: String, userToken: String) -> [String: String] { + var headers = sessionRuntime.siteHeaders(contentType: true) + headers["X-User-Token"] = userToken + headers["X-Session-Id"] = session + return headers + } + + func rawAuthorizedHeaders(session: String, userToken: String) -> [String: String] { + var headers = sessionRuntime.siteHeaders(contentType: false) + headers["X-User-Token"] = userToken + headers["X-Session-Id"] = session + return headers + } + + func webSocketURL( + session: String, + userToken: String, + updatedAt: String? + ) -> URL { + var components = URLComponents( + url: sessionRuntime.config.endpoints.clientWebSocketEndpointURL, + resolvingAgainstBaseURL: false + )! + + components.queryItems = realtimeQueryItems( + session: session, + userToken: userToken, + updatedAt: updatedAt + ) + + return components.url! + } + + func fileLinkURL(fileID: String) -> URL { + sessionRuntime.config.endpoints.clientAPIEndpointURL + .appendingPathComponent("files") + .appendingPathComponent(fileID) + } + + func actionExecuteURL(actionID: String) -> URL { + sessionRuntime.config.endpoints.clientAPIEndpointURL + .appendingPathComponent("actions") + .appendingPathComponent(actionID) + .appendingPathComponent("execute") + } + + func actionShowURL(actionID: String) -> URL { + sessionRuntime.config.endpoints.clientAPIEndpointURL + .appendingPathComponent("actions") + .appendingPathComponent(actionID) + .appendingPathComponent("show") + } + + func contactFormURL(actionID: String?) -> URL { + let url = endpointURL("contact/form") + guard let actionID = actionID?.nilIfEmpty else { + return url + } + + var components = URLComponents( + url: url, + resolvingAgainstBaseURL: false + )! + components.queryItems = [ + URLQueryItem(name: "action_id", value: actionID) + ] + return components.url! + } + + func messagesURL( + limit: Int, + offset: Int + ) -> URL { + var components = URLComponents( + url: endpointURL("messages"), + resolvingAgainstBaseURL: false + )! + + components.queryItems = [ + URLQueryItem(name: "limit", value: String(max(1, min(limit, 1_000)))), + URLQueryItem(name: "Offset", value: String(max(0, offset))) + ] + + return components.url! + } + + func messageReadURL(messageID: String) -> URL { + sessionRuntime.config.endpoints.clientAPIEndpointURL + .appendingPathComponent("messages") + .appendingPathComponent(messageID) + .appendingPathComponent("read") + } + + func realtimeQueryItems( + session: String, + userToken: String, + updatedAt: String? + ) -> [URLQueryItem] { + [ + URLQueryItem(name: "token", value: userToken), + URLQueryItem(name: "session", value: session), + URLQueryItem(name: "updated_at", value: updatedAt ?? "undefined") + ] + } +} + +/// Error returned by SDK network operations. +package enum ConsultantClientError: Error, Equatable, LocalizedError { + + /// Connect response does not contain required user token or session id. + case missingConnectionCredentials + + /// Backend returned a non-success HTTP status with response messages. + case httpStatus(Int, [String]) + + /// Request, encoding, or decoding failed before a typed backend response could be returned. + case requestFailed(String) + + package var errorDescription: String? { + switch self { + case .missingConnectionCredentials: + return ConsultantLocalization.clientErrorMissingCredentials.localized + case .httpStatus(let statusCode, let messages): + let description = ConsultantLocalization.clientErrorHTTPStatus.format(statusCode) + return messages.isEmpty ? description : description + " " + messages.joined(separator: " ") + case .requestFailed(let message): + return ConsultantLocalization.clientErrorRequestFailed.format(message) + } + } +} + +private extension ConsultantClientError { + static func fromHTTP(statusCode: Int, body: Data) -> Self { + .httpStatus(statusCode, CoreError.errorMessages(from: body)) + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/ConsultantControllerError.swift b/Sources/OnlineConsultant/ConsultantController/ConsultantControllerError.swift new file mode 100644 index 0000000..c9ffebf --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ConsultantControllerError.swift @@ -0,0 +1,47 @@ +// +// ConsultantControllerError.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 23.07.2026. +// + +import Foundation + +/// Domain errors produced by `OnlineConsultantController` before or around backend calls. +/// +/// Covers local validation failures such as missing connection credentials, unavailable consultant +/// configuration, incomplete upload responses, and input attachments that cannot be sent yet. Each +/// case also provides a user-readable localized description. +package enum ConsultantControllerError: Error, Equatable, LocalizedError { + + /// Operation requires an active consultant connection. + case notConnected + /// Connect response does not contain required session credentials. + case missingCredentials + /// Backend configuration says that the consultant is unavailable. + case unavailable + /// File upload response does not contain a backend file id. + case missingUploadedFileID + /// Message attachments are not uploaded yet. + case inputAttachmentsNotReady + /// Attachment was removed before upload finished. + case inputAttachmentRemoved + + /// User-readable error description. + package var errorDescription: String? { + switch self { + case .notConnected: + return ConsultantLocalization.controllerErrorNotConnected.localized + case .missingCredentials: + return ConsultantLocalization.controllerErrorMissingCredentials.localized + case .unavailable: + return ConsultantLocalization.controllerErrorUnavailable.localized + case .missingUploadedFileID: + return ConsultantLocalization.controllerErrorMissingUploadedFileID.localized + case .inputAttachmentsNotReady: + return ConsultantLocalization.controllerErrorInputAttachmentsNotUploaded.localized + case .inputAttachmentRemoved: + return ConsultantLocalization.controllerErrorInputAttachmentRemoved.localized + } + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/ContactFormActionBuilder.swift b/Sources/OnlineConsultant/ConsultantController/ContactFormActionBuilder.swift new file mode 100644 index 0000000..74fb15b --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ContactFormActionBuilder.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Builds a local contact-form message from a supported site action. +package enum ContactFormActionBuilder { + + package static func message( + from action: SiteAction, + contactFormUser: ContactFormUser + ) -> ConsultantMessage? { + guard let contactForm = action.makeContactForm(for: contactFormUser) else { + return nil + } + + let timestamp = DateParser.string(from: Date()) + let items = action.messageItems + + return ConsultantMessage( + id: FlexibleID("_action_\(action.id?.value ?? UUID().uuidString)"), + content: contactForm.text, + header: action.header?.nilIfEmpty, + items: items.isEmpty ? nil : items, + time: timestamp, + updatedAt: timestamp, + user: MessageUser( + firstName: ConsultantLocalization.messageAuthorBotAssistant.localized + ), + fromMe: false, + messageType: .contactForm, + status: .delivered, + contactForm: contactForm + ) + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/ContactFormActionCoordinator.swift b/Sources/OnlineConsultant/ConsultantController/ContactFormActionCoordinator.swift new file mode 100644 index 0000000..3d16ed5 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ContactFormActionCoordinator.swift @@ -0,0 +1,139 @@ +import Foundation + +/// Selects and tracks the first displayable contact-form action. +@MainActor +package final class ContactFormActionCoordinator { + + private let client: OnlineConsultantClient + private var shownActionIDs: Set = [] + private var reportedShownActionIDs: Set = [] + + package init(client: OnlineConsultantClient) { + self.client = client + } + + package func reset() { + shownActionIDs.removeAll() + reportedShownActionIDs.removeAll() + } + + package func initialContactFormMessageIfNeeded( + actions: [SiteAction], + contactFormUser: ContactFormUser, + messagesAreEmpty: Bool, + generation: UUID, + isCurrentGeneration: @escaping (UUID) -> Bool, + currentCredentials: @escaping () throws -> (sessionID: String, userToken: String), + handleError: @escaping (Error) -> Void + ) -> ConsultantMessage? { + guard messagesAreEmpty else { + return nil + } + + guard let (action, message) = actions.lazy.compactMap({ action -> (SiteAction, ConsultantMessage)? in + if let actionID = action.id?.value, self.shownActionIDs.contains(actionID) { + return nil + } + + guard let message = ContactFormActionBuilder.message( + from: action, + contactFormUser: contactFormUser + ) else { + return nil + } + + return (action, message) + }).first else { + return nil + } + + if let actionID = action.id?.value { + shownActionIDs.insert(actionID) + startActionLifecycle( + actionID: actionID, + generation: generation, + isCurrentGeneration: isCurrentGeneration, + currentCredentials: currentCredentials, + handleError: handleError + ) + } + + return message + } + + private func startActionLifecycle( + actionID: String, + generation: UUID, + isCurrentGeneration: @escaping (UUID) -> Bool, + currentCredentials: @escaping () throws -> (sessionID: String, userToken: String), + handleError: @escaping (Error) -> Void + ) { + Task { [weak self] in + guard let self else { return } + + await executeAction( + actionID: actionID, + generation: generation, + isCurrentGeneration: isCurrentGeneration, + currentCredentials: currentCredentials, + handleError: handleError + ) + await showActionIfNeeded( + actionID: actionID, + generation: generation, + isCurrentGeneration: isCurrentGeneration, + currentCredentials: currentCredentials, + handleError: handleError + ) + } + } + + private func executeAction( + actionID: String, + generation: UUID, + isCurrentGeneration: (UUID) -> Bool, + currentCredentials: () throws -> (sessionID: String, userToken: String), + handleError: (Error) -> Void + ) async { + do { + let credentials = try currentCredentials() + _ = try await client.executeAction( + actionID: actionID, + session: credentials.sessionID, + userToken: credentials.userToken + ) + } catch { + guard isCurrentGeneration(generation), !Task.isCancelled else { return } + + handleError(error) + } + } + + private func showActionIfNeeded( + actionID: String, + generation: UUID, + isCurrentGeneration: (UUID) -> Bool, + currentCredentials: () throws -> (sessionID: String, userToken: String), + handleError: (Error) -> Void + ) async { + guard !reportedShownActionIDs.contains(actionID) else { + return + } + + do { + let credentials = try currentCredentials() + try await client.showAction( + actionID: actionID, + session: credentials.sessionID, + userToken: credentials.userToken + ) + guard isCurrentGeneration(generation), !Task.isCancelled else { return } + + reportedShownActionIDs.insert(actionID) + } catch { + guard isCurrentGeneration(generation), !Task.isCancelled else { return } + + handleError(error) + } + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/ContactFormState.swift b/Sources/OnlineConsultant/ConsultantController/ContactFormState.swift new file mode 100644 index 0000000..407257c --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ContactFormState.swift @@ -0,0 +1,90 @@ +/// Local state holder for contact-form presentation and prefilled user data. +/// +/// Derives contact-form user fields from the connected backend user and SDK fallback user, tracks +/// whether contact and offline forms have already been submitted locally, merges submitted data back +/// into local state, and calculates whether the current contact form should be shown or hidden. + +import Core + +package struct ContactFormState { + + private var hasSubmittedContactForm = false + package private(set) var hasSubmittedOfflineForm = false + package private(set) var user = ContactFormUser() + + /// Clears local contact-form state. + package mutating func reset() { + hasSubmittedContactForm = false + hasSubmittedOfflineForm = false + user = ContactFormUser() + } + + /// Applies connected backend user data and SDK-provided fallback user values. + package mutating func applyConnectedUser( + _ connectedUser: ConnectedUser, + fallbackUser: ConnectUser + ) { + hasSubmittedContactForm = false + hasSubmittedOfflineForm = false + user = ContactFormUser( + connectedUser: connectedUser, + fallbackUser: fallbackUser + ) + } + + /// Applies submitted form data to local user state. + package mutating func applySubmitData( + _ data: ContactFormSubmitData, + kind: ContactFormKind = .contact + ) { + switch kind { + case .contact: + hasSubmittedContactForm = true + case .offline: + hasSubmittedOfflineForm = true + } + user = user.merging(data) + } + + /// Returns whether a form of the provided kind has already been submitted locally. + package func hasSubmittedForm(kind: ContactFormKind) -> Bool { + switch kind { + case .contact: + return hasSubmittedContactForm + case .offline: + return hasSubmittedOfflineForm + } + } + + /// Calculates current contact-form presentation state. + package func presentation( + response: ConsultantConnectResponse?, + messages: [ConsultantMessage], + isOffline: Bool + ) -> ContactFormPresentation { + guard !hasSubmittedContactForm else { + return .hidden + } + + guard let response else { + return .hidden + } + + return ContactFormPresentation.make( + response: response, + messages: messages, + isOffline: isOffline, + user: user + ) + } + + package init( + hasSubmittedContactForm: Bool = false, + hasSubmittedOfflineForm: Bool = false, + user: ContactFormUser = ContactFormUser() + ) { + self.hasSubmittedContactForm = hasSubmittedContactForm + self.hasSubmittedOfflineForm = hasSubmittedOfflineForm + self.user = user + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/ControllerEvent.swift b/Sources/OnlineConsultant/ConsultantController/ControllerEvent.swift new file mode 100644 index 0000000..9ee0d8e --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ControllerEvent.swift @@ -0,0 +1,28 @@ +// +// ControllerEvent.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 23.07.2026. +// + +/// Discrete controller events emitted in addition to state and message streams. +/// +/// Provides host applications with important one-time notifications such as successful connection, +/// decoded realtime payloads, contact-form submission, offline status changes, and operation errors. + package enum ControllerEvent: Equatable, Sendable { + + /// Controller state changed. + case stateChanged(ControllerState) + /// Loaded unread incoming-message count changed. + case unreadIncomingMessagesCountChanged(Int) + /// Connect flow completed successfully. + case connected(ConsultantConnectResponse) + /// Backend realtime event was received and decoded. + case realtimeEvent(ConsultantRealtimeEvent) + /// Contact form was submitted successfully. + case contactFormSubmitted(ContactFormSubmitData) + /// Consultant offline state changed. + case offlineStatusChanged(Bool) + /// Controller operation failed with a user-readable message. + case error(String) +} diff --git a/Sources/OnlineConsultant/ConsultantController/ControllerInternalState.swift b/Sources/OnlineConsultant/ConsultantController/ControllerInternalState.swift new file mode 100644 index 0000000..7fd9006 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ControllerInternalState.swift @@ -0,0 +1,23 @@ +// +// ControllerInternalState.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 24.07.2026. +// + +/// Internal lifecycle state used by SDK UI. +/// +/// Mirrors the public controller state but keeps the ready-state snapshot available for internal +/// UIKit components that need messages, consultant configuration, pagination, and contact-form state +/// atomically. +package enum ControllerInternalState: Equatable, Sendable { + + /// No active connection. + case idle + /// Connection flow is in progress. + case connecting + /// Controller is connected and has a current state snapshot for SDK UI. + case ready(ControllerSnapshot) + /// Connection or controller operation failed with a user-readable message. + case failed(String) +} diff --git a/Sources/OnlineConsultant/ConsultantController/ControllerSnapshot.swift b/Sources/OnlineConsultant/ConsultantController/ControllerSnapshot.swift new file mode 100644 index 0000000..91a0828 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ControllerSnapshot.swift @@ -0,0 +1,59 @@ +// +// ControllerSnapshot.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 23.07.2026. +// + +/// Immutable ready-state snapshot used inside `ConsultantController`. +/// +/// Bundles the latest backend connect response, active credentials, messages, consultant configuration, +/// offline state, unread incoming-message badge state, pagination flag, and contact-form +/// presentation into one value that SDK UI code can consume atomically. +package struct ControllerSnapshot: Equatable, Sendable { + + /// Latest backend connect response. + package let connectResponse: ConsultantConnectResponse + /// User token used for authorized backend requests. + package let userToken: String? + /// Session id used for authorized backend requests. + package let sessionID: String? + /// Messages available in the current controller state. + package let messages: [ConsultantMessage] + /// consultant configuration received from the backend. + package let consultantConfig: ConsultantConfig? + /// Indicates whether the consultant is currently offline. + package let isOffline: Bool + /// Indicates whether the message input must be hidden by active offline mode. + package let blocksMessageInput: Bool + /// Number of unread incoming messages in the loaded history; zero when the counter is disabled. + package let unreadIncomingMessagesCount: Int + /// Indicates whether older messages can be loaded. + package let canLoadOlderMessages: Bool + /// Current contact-form presentation state. + package let contactFormPresentation: ContactFormPresentation + + package init( + connectResponse: ConsultantConnectResponse, + userToken: String?, + sessionID: String?, + messages: [ConsultantMessage], + consultantConfig: ConsultantConfig?, + isOffline: Bool, + blocksMessageInput: Bool, + unreadIncomingMessagesCount: Int, + canLoadOlderMessages: Bool, + contactFormPresentation: ContactFormPresentation + ) { + self.connectResponse = connectResponse + self.userToken = userToken + self.sessionID = sessionID + self.messages = messages + self.consultantConfig = consultantConfig + self.isOffline = isOffline + self.blocksMessageInput = blocksMessageInput + self.unreadIncomingMessagesCount = unreadIncomingMessagesCount + self.canLoadOlderMessages = canLoadOlderMessages + self.contactFormPresentation = contactFormPresentation + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/ControllerState.swift b/Sources/OnlineConsultant/ConsultantController/ControllerState.swift new file mode 100644 index 0000000..263ae39 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/ControllerState.swift @@ -0,0 +1,24 @@ +// +// ControllerState.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 23.07.2026. +// + +/// High-level lifecycle state published by `OnlineConsultantController`. +/// +/// Represents whether the controller has no active session, is currently connecting, is ready, or +/// failed with a typed error. Host applications can observe this value to update chat +/// availability and loading UI without depending on internal SDK session details. +public enum ControllerState: Equatable, Sendable { + + /// No active connection. + case idle + /// Connection flow is in progress. + case connecting + /// Controller is connected and ready to work with the current session. + /// `isAnonymous` is true when no customer identity was supplied for this session. + case ready(isAnonymous: Bool) + /// Connection or controller operation failed. + case failed(ConsultantError) +} diff --git a/Sources/OnlineConsultant/ConsultantController/InputAttachmentStore.swift b/Sources/OnlineConsultant/ConsultantController/InputAttachmentStore.swift new file mode 100644 index 0000000..2f35adc --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/InputAttachmentStore.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Local store for attachments prepared in the message input. +/// +/// Owns the ordered input attachment list, creates upload placeholders, updates upload status and +/// preview data, exposes send readiness, and removes attachments after sending or user actions. It +/// does not perform uploads itself; network work stays in the controller/client layer. +package struct InputAttachmentStore { + + /// Attachments currently prepared in the message input. + package private(set) var attachments: [InputAttachment] = [] + + /// Indicates whether every prepared attachment is ready to be sent. + package var canSend: Bool { + attachments.allSatisfy(\.canSend) + } + + /// Creates and appends a new input attachment. + @discardableResult + package mutating func add( + fileName: String, + contentType: String = "application/octet-stream", + previewData: Data? = nil, + messageType: String = "file" + ) -> InputAttachment { + let attachment = InputAttachment( + fileName: fileName, + contentType: contentType, + previewData: previewData, + messageType: messageType, + status: .uploading + ) + attachments.append(attachment) + return attachment + } + + /// Returns the attachment with the matching local id. + package func attachment(id: UUID) -> InputAttachment? { + attachments.first { $0.id == id } + } + + /// Updates upload status for an existing attachment. + @discardableResult + package mutating func updateStatus( + id: UUID, + status: InputAttachmentStatus + ) -> InputAttachment? { + guard let index = attachments.firstIndex(where: { $0.id == id }) else { + return nil + } + + guard attachments[index].status != status else { + return attachments[index] + } + + let attachment = attachments[index].updatingStatus(status) + attachments[index] = attachment + return attachment + } + + /// Updates preview data for an existing attachment. + package mutating func updatePreviewData( + id: UUID, + previewData: Data + ) -> Bool { + guard let index = attachments.firstIndex(where: { $0.id == id }) else { + return false + } + + guard attachments[index].previewData != previewData else { + return false + } + + attachments[index] = attachments[index].updatingPreviewData(previewData) + return true + } + + /// Removes an attachment by local id. + package mutating func remove(id: UUID) -> Bool { + let oldCount = attachments.count + attachments.removeAll { $0.id == id } + return attachments.count != oldCount + } + + /// Removes attachments by local ids. + package mutating func remove(ids: [UUID]) -> Bool { + let ids = Set(ids) + let oldCount = attachments.count + attachments.removeAll { ids.contains($0.id) } + return attachments.count != oldCount + } + + /// Removes every stored input attachment. + package mutating func removeAll() -> Bool { + guard !attachments.isEmpty else { + return false + } + + attachments.removeAll() + return true + } + + package init(attachments: [InputAttachment] = []) { + self.attachments = attachments + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/MessageFileLinkCoordinator.swift b/Sources/OnlineConsultant/ConsultantController/MessageFileLinkCoordinator.swift new file mode 100644 index 0000000..363c077 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/MessageFileLinkCoordinator.swift @@ -0,0 +1,289 @@ +import Core +import Foundation + +/// Coordinator for resolving message attachment file links. +/// +/// Converts backend file item identifiers into usable URLs by checking the local file cache, reusing +/// stored remote URLs, calling the backend file-link endpoint for missing data, and caching remote +/// files in the background. It also owns cancellation for file-link and remote-cache tasks. +@MainActor +package final class MessageFileLinkCoordinator { + + private let client: OnlineConsultantClient + private let fileCache: ConsultantFileCache + private let remoteFileCacheLoader: ConsultantRemoteFileCacheLoading + private let cachePolicy: OnlineConsultantCachePolicy + private var fileLinkTasks: [String: Task] = [:] + private var remoteFileCacheTasks: [String: Task] = [:] + private var fileLinkGeneration = UUID() + + /// Creates a coordinator for message file-link resolution and background caching. + package init( + client: OnlineConsultantClient, + fileCache: ConsultantFileCache, + remoteFileCacheLoader: ConsultantRemoteFileCacheLoading, + cachePolicy: OnlineConsultantCachePolicy + ) { + self.client = client + self.fileCache = fileCache + self.remoteFileCacheLoader = remoteFileCacheLoader + self.cachePolicy = cachePolicy + } + + /// Resolves missing file links from local cache, stored remote URLs, and backend file-link API. + package func resolveMissingFileLinks( + unresolvedFileIDs: Set, + credentials: MessageFileLinkCredentials?, + applyFileLinks: @escaping ([String: String]) -> Void, + handleError: @escaping (Error) -> Void + ) { + var unresolvedFileIDs = unresolvedFileIDs + guard !unresolvedFileIDs.isEmpty else { + return + } + + let localLinks = cachedLocalFileLinks(for: unresolvedFileIDs) + applyFileLinksIfNeeded( + localLinks, + applyFileLinks: applyFileLinks + ) + unresolvedFileIDs.subtract(localLinks.keys) + + let remoteLinks = cachedRemoteFileLinks(for: unresolvedFileIDs) + applyFileLinksIfNeeded( + remoteLinks, + applyFileLinks: applyFileLinks + ) + unresolvedFileIDs.subtract(remoteLinks.keys) + cacheRemoteFileLinks( + remoteLinks, + applyFileLinks: applyFileLinks, + handleError: handleError + ) + + loadMissingRemoteFileLinks( + fileIDs: unresolvedFileIDs, + credentials: credentials, + applyFileLinks: applyFileLinks, + handleError: handleError + ) + } + + /// Cancels all active file-link and remote-file cache tasks. + package func cancel() { + fileLinkTasks.values.forEach { $0.cancel() } + fileLinkTasks.removeAll() + cancelRemoteCaching() + fileLinkGeneration = UUID() + } + + /// Cancels only background remote-file cache tasks. + package func cancelRemoteCaching() { + remoteFileCacheTasks.values.forEach { $0.cancel() } + remoteFileCacheTasks.removeAll() + } + + private func applyFileLinksIfNeeded( + _ links: [String: String], + applyFileLinks: ([String: String]) -> Void + ) { + guard !links.isEmpty else { + return + } + + applyFileLinks(links) + } + + private func loadMissingRemoteFileLinks( + fileIDs: Set, + credentials: MessageFileLinkCredentials?, + applyFileLinks: @escaping ([String: String]) -> Void, + handleError: @escaping (Error) -> Void + ) { + guard let credentials else { + return + } + + for fileID in fileIDs where fileLinkTasks[fileID] == nil { + let generation = fileLinkGeneration + fileLinkTasks[fileID] = Task { [weak self] in + guard let self else { return } + + defer { + if fileLinkGeneration == generation { + fileLinkTasks[fileID] = nil + } + } + + do { + let response = try await client.loadFileLink( + fileID: fileID, + session: credentials.sessionID, + userToken: credentials.userToken + ) + + guard + !Task.isCancelled, + fileLinkGeneration == generation, + let url = response.url?.trimmingCharacters(in: .whitespacesAndNewlines), + !url.isEmpty + else { + return + } + + persistRemoteFileLink( + itemID: fileID, + url: url, + handleError: handleError + ) + applyFileLinks([fileID: url]) + cacheRemoteFileLink( + itemID: fileID, + url: url, + applyFileLinks: applyFileLinks, + handleError: handleError + ) + } catch { + guard !Task.isCancelled else { + return + } + + handleError(error) + } + } + } + } + + private func cachedLocalFileLinks(for fileIDs: Set) -> [String: String] { + fileIDs.reduce(into: [:]) { result, fileID in + guard let localURL = fileCache.cachedFileURL(fileID: fileID) else { + return + } + + result[fileID] = localURL.absoluteString + } + } + + private func cachedRemoteFileLinks(for fileIDs: Set) -> [String: String] { + fileIDs.reduce(into: [:]) { result, fileID in + guard let remoteURL = fileCache.entry(fileID: fileID)?.remoteURL else { + return + } + + result[fileID] = remoteURL.absoluteString + } + } + + private func persistRemoteFileLink( + itemID: String, + url: String, + handleError: (Error) -> Void + ) { + guard let remoteURL = URL(string: url) else { + return + } + + do { + try fileCache.updateRemoteURL( + fileID: itemID, + remoteURL: remoteURL + ) + } catch { + handleError(ConsultantError.cacheError(from: error)) + } + } + + private func cacheRemoteFileLinks( + _ links: [String: String], + applyFileLinks: @escaping ([String: String]) -> Void, + handleError: @escaping (Error) -> Void + ) { + links.forEach { itemID, url in + cacheRemoteFileLink( + itemID: itemID, + url: url, + applyFileLinks: applyFileLinks, + handleError: handleError + ) + } + } + + private func cacheRemoteFileLink( + itemID: String, + url: String, + applyFileLinks: @escaping ([String: String]) -> Void, + handleError: @escaping (Error) -> Void + ) { + guard + remoteFileCacheTasks[itemID] == nil, + fileCache.cachedFileURL(fileID: itemID) == nil, + let remoteURL = URL(string: url), + !remoteURL.isFileURL + else { + return + } + + let generation = fileLinkGeneration + remoteFileCacheTasks[itemID] = Task { [weak self] in + guard let self else { return } + + defer { + if fileLinkGeneration == generation { + remoteFileCacheTasks[itemID] = nil + } + } + + do { + let localURL = try await remoteFileCacheLoader.cacheRemoteFile( + fileID: itemID, + remoteURL: remoteURL + ) + trimCacheIfNeeded(handleError: handleError) + + guard + !Task.isCancelled, + fileLinkGeneration == generation + else { + return + } + applyFileLinks([itemID: localURL.absoluteString]) + } catch { + guard !Task.isCancelled else { + return + } + + handleError(error) + } + } + } + + private func trimCacheIfNeeded(handleError: (Error) -> Void) { + guard cachePolicy.automaticCleanup else { + return + } + + do { + _ = try fileCache.trim(policy: cachePolicy) + } catch { + handleError(ConsultantError.cacheError(from: error)) + } + } +} + +/// Credentials required by message file-link requests. +/// +/// Keeps the current session id and user token together so the coordinator can call the authorized +/// backend file-link endpoint without depending on the full controller state. +package struct MessageFileLinkCredentials { + + /// Current consultant session identifier. + package let sessionID: String + + /// Current consultant user token. + package let userToken: String + + package init(sessionID: String, userToken: String) { + self.sessionID = sessionID + self.userToken = userToken + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/MessageHistoryCoordinator.swift b/Sources/OnlineConsultant/ConsultantController/MessageHistoryCoordinator.swift new file mode 100644 index 0000000..76d1a19 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/MessageHistoryCoordinator.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Shares page requests between normal pagination and quote navigation. +@MainActor +package final class MessageHistoryCoordinator { + private let messages: () -> [ConsultantMessage] + private let canLoadMore: () -> Bool + private let loadPage: () async throws -> MessagesPage + private var pageTask: Task? + private var pageRequestID: UUID? + + package init( + messages: @escaping () -> [ConsultantMessage], + canLoadMore: @escaping () -> Bool, + loadPage: @escaping () async throws -> MessagesPage + ) { + self.messages = messages + self.canLoadMore = canLoadMore + self.loadPage = loadPage + } + + deinit { pageTask?.cancel() } + + package func cancel() { + pageTask?.cancel() + pageTask = nil + pageRequestID = nil + } + + package func loadNextPage() async throws -> MessagesPage { + if let pageTask { return try await pageTask.value } + let requestID = UUID() + let loader = loadPage + let task = Task { try await loader() } + pageTask = task + pageRequestID = requestID + defer { + if pageRequestID == requestID { + pageTask = nil + pageRequestID = nil + } + } + return try await task.value + } + + /// Loads older pages until the target is found or history is exhausted. + package func findMessage(id: String) async throws -> Bool { + while true { + try Task.checkCancellation() + if messages().contains(where: { $0.id?.value == id && $0.status != .deleted }) { return true } + guard canLoadMore() else { return false } + let knownIDs = Set(messages().compactMap { $0.id?.value }) + let page = try await loadNextPage() + try Task.checkCancellation() + if messages().contains(where: { $0.id?.value == id && $0.status != .deleted }) { return true } + guard page.hasMoreMessages, !page.messages.isEmpty, + Set(messages().compactMap { $0.id?.value }) != knownIDs else { return false } + } + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/MessageReadCoordinator.swift b/Sources/OnlineConsultant/ConsultantController/MessageReadCoordinator.swift new file mode 100644 index 0000000..6611dc9 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/MessageReadCoordinator.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Coordinator for backend message read-state updates. +/// +/// Coalesces visible incoming message ids into one debounced backend `read` request, keeps only the +/// greatest pending backend message id, and ignores ids that were already submitted during the +/// current controller lifecycle. +@MainActor +package final class MessageReadCoordinator { + + private let client: OnlineConsultantClient + private let debounceNanoseconds: UInt64 + private var pendingMessageID: UInt64? + private var lastSubmittedMessageID: UInt64? + private var readTask: Task? + private var generation = UUID() + + /// Creates a coordinator with the client used for backend read requests. + package init( + client: OnlineConsultantClient, + debounceNanoseconds: UInt64 = 500_000_000 + ) { + self.client = client + self.debounceNanoseconds = debounceNanoseconds + } + + /// Schedules a debounced read-state update for the provided backend message id. + package func schedule( + messageID: String, + session: String, + userToken: String, + handleError: @escaping (Error) -> Void + ) { + guard let numericMessageID = UInt64(messageID) else { + return + } + + if let lastSubmittedMessageID, numericMessageID <= lastSubmittedMessageID { + return + } + + if let pendingMessageID, numericMessageID <= pendingMessageID { + return + } + + pendingMessageID = numericMessageID + readTask?.cancel() + + let generation = generation + let debounceNanoseconds = debounceNanoseconds + readTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: debounceNanoseconds) + } catch { + return + } + + await self?.flush( + generation: generation, + session: session, + userToken: userToken, + handleError: handleError + ) + } + } + + /// Cancels a pending read-state update without clearing the last submitted id. + package func cancel() { + readTask?.cancel() + readTask = nil + pendingMessageID = nil + generation = UUID() + } + + /// Cancels pending work and clears submitted read-state tracking. + package func reset() { + cancel() + lastSubmittedMessageID = nil + } + + private func flush( + generation: UUID, + session: String, + userToken: String, + handleError: @escaping (Error) -> Void + ) async { + guard self.generation == generation, let messageID = pendingMessageID else { + return + } + + pendingMessageID = nil + readTask = nil + + do { + try await client.markMessageRead( + messageID: String(messageID), + session: session, + userToken: userToken + ) + guard self.generation == generation, !Task.isCancelled else { + return + } + + lastSubmittedMessageID = max(lastSubmittedMessageID ?? 0, messageID) + } catch { + guard self.generation == generation, !Task.isCancelled else { + return + } + + handleError(error) + } + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/MessageSender.swift b/Sources/OnlineConsultant/ConsultantController/MessageSender.swift new file mode 100644 index 0000000..d1a12d3 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/MessageSender.swift @@ -0,0 +1,279 @@ +import Foundation + +/// Coordinator for sending visitor messages through the backend REST API. +/// +/// Creates local optimistic messages through callbacks, sends text or uploaded attachment payloads, +/// maps input attachments to backend `ConsultantSendMessageItem` values, keeps image preview data for +/// local rendering, replaces local messages with backend acknowledgements, and marks failed local +/// messages when sending does not complete. +@MainActor +package final class MessageSender { + + private let client: OnlineConsultantClient + + /// Creates a sender with the client used for message requests. + package init(client: OnlineConsultantClient) { + self.client = client + } + + /// Sends a failed message again, keeping its existing row and uploaded attachment identifiers. + package func retryMessage( + _ message: ConsultantMessage, + credentials: MessageSendCredentials, + callbacks: MessageSenderCallbacks + ) async throws { + guard message.canRetrySending, let id = message.id?.value, + let type = message.messageType else { return } + let items = (message.items ?? []).compactMap { item -> ConsultantSendMessageItem? in + guard let id = item.id else { return nil } + return ConsultantSendMessageItem(id: id, caption: item.caption) + } + callbacks.updateLocalMessageStatus(id, .sending) + do { + let sentMessage = try await client.sendMessage( + text: message.content, + type: type, + items: items, + session: credentials.sessionID, + userToken: credentials.userToken + ) + callbacks.replaceLocalMessage(id, sentMessage) + } catch { + callbacks.updateLocalMessageStatus(id, .failed) + callbacks.handleError(error) + throw error + } + } + + /// Sends a text message and reports local message mutations through callbacks. + package func sendTextMessage( + _ text: String, + credentials: MessageSendCredentials, + callbacks: MessageSenderCallbacks + ) async throws { + let localMessageID = callbacks.appendLocalMessage(text, .text, nil) + + do { + let sentMessage = try await client.sendMessage( + text, + session: credentials.sessionID, + userToken: credentials.userToken + ) + callbacks.replaceLocalMessage(localMessageID, sentMessage) + } catch { + callbacks.updateLocalMessageStatus(localMessageID, .failed) + callbacks.handleError(error) + throw error + } + } + + /// Sends uploaded attachments and their text as separate messages, tracking each result independently. + package func sendAttachmentMessages( + text: String, + attachments: [InputAttachment], + credentials: MessageSendCredentials, + callbacks: MessageSenderCallbacks + ) async throws { + let outgoingMessages = try makeOutgoingMessages( + text: text, + attachments: attachments + ) + let localMessageIDs = outgoingMessages.map { outgoingMessage in + callbacks.appendLocalMessage( + outgoingMessage.text, + outgoingMessage.messageType, + outgoingMessage.items.isEmpty ? nil : makeLocalMessageItems( + from: outgoingMessage.items, + attachments: outgoingMessage.attachments, + messageType: outgoingMessage.messageType + ) + ) + } + callbacks.removeInputAttachments(attachments.map(\.id)) + + var firstError: Error? + for (index, outgoingMessage) in outgoingMessages.enumerated() { + let localMessageID = localMessageIDs[index] + + do { + let sentMessage = try await client.sendMessage( + text: outgoingMessage.text.isEmpty ? nil : outgoingMessage.text, + type: outgoingMessage.messageType, + items: outgoingMessage.items, + session: credentials.sessionID, + userToken: credentials.userToken + ) + callbacks.replaceLocalMessage(localMessageID, sentMessage) + } catch { + callbacks.updateLocalMessageStatus(localMessageID, .failed) + callbacks.handleError(error) + if firstError == nil { firstError = error } + if error is CancellationError || Task.isCancelled { + localMessageIDs.dropFirst(index + 1).forEach { + callbacks.updateLocalMessageStatus($0, .failed) + } + break + } + } + } + if let firstError { throw firstError } + } + + private func makeSendMessageItems( + from attachments: [InputAttachment] + ) throws -> [ConsultantSendMessageItem] { + try attachments.map { attachment in + guard let fileID = attachment.uploadedFileID else { + throw ConsultantControllerError.inputAttachmentsNotReady + } + + return ConsultantSendMessageItem( + id: fileID.value, + caption: attachment.fileName + ) + } + } + + private func makeOutgoingMessages( + text: String, + attachments: [InputAttachment] + ) throws -> [OutgoingMessage] { + let imageAttachments = attachments.filter { $0.messageType == "image" } + let fileAttachments = attachments.filter { $0.messageType != "image" } + + var messages = try [ + makeOutgoingAttachmentMessage(messageType: .image, attachments: imageAttachments), + makeOutgoingAttachmentMessage(messageType: .file, attachments: fileAttachments) + ].compactMap { $0 } + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + messages.append(OutgoingMessage(text: text, messageType: .text, attachments: [], items: [])) + } + return messages + } + + private func makeOutgoingAttachmentMessage( + messageType: MessageType, + attachments: [InputAttachment] + ) throws -> OutgoingMessage? { + guard !attachments.isEmpty else { + return nil + } + + return OutgoingMessage( + text: "", + messageType: messageType, + attachments: attachments, + items: try makeSendMessageItems(from: attachments) + ) + } + + private func makeLocalMessageItems( + from items: [ConsultantSendMessageItem], + attachments: [InputAttachment], + messageType: MessageType + ) -> [MessageItem] { + zip(items, attachments).map { item, attachment in + var previewDataURL: String? + + if + messageType == .image, + let localPreviewDataURL = localImagePreviewDataURL(from: attachment) + { + previewDataURL = localPreviewDataURL + } + + return MessageItem( + id: item.id, + caption: item.caption, + url: previewDataURL + ) + } + } + + private func localImagePreviewDataURL( + from attachment: InputAttachment + ) -> String? { + guard + attachment.messageType == "image", + attachment.contentType.hasPrefix("image/"), + let previewData = attachment.previewData + else { + return nil + } + + return "data:\(attachment.contentType);base64,\(previewData.base64EncodedString())" + } +} + +/// Callback set used by `MessageSender` to mutate controller-owned state. +/// +/// Keeps the sender focused on building and performing send requests while the controller remains the +/// owner of message storage, input attachment removal, and error reporting. +package struct MessageSenderCallbacks { + + /// Appends a local outgoing message and returns its local identifier. + package let appendLocalMessage: (String, MessageType, [MessageItem]?) -> String + + /// Removes sent input attachments from the input queue. + package let removeInputAttachments: ([UUID]) -> Void + + /// Replaces a local message with backend acknowledgement data. + package let replaceLocalMessage: (String, ConsultantSentMessage) -> Void + + /// Updates a local message delivery status. + package let updateLocalMessageStatus: (String, MessageStatus) -> Void + + /// Handles an error produced by message sending. + package let handleError: (Error) -> Void + + package init( + appendLocalMessage: @escaping (String, MessageType, [MessageItem]?) -> String, + removeInputAttachments: @escaping ([UUID]) -> Void, + replaceLocalMessage: @escaping (String, ConsultantSentMessage) -> Void, + updateLocalMessageStatus: @escaping (String, MessageStatus) -> Void, + handleError: @escaping (Error) -> Void + ) { + self.appendLocalMessage = appendLocalMessage + self.removeInputAttachments = removeInputAttachments + self.replaceLocalMessage = replaceLocalMessage + self.updateLocalMessageStatus = updateLocalMessageStatus + self.handleError = handleError + } +} + +/// Credentials required for authorized backend send-message requests. +/// +/// Groups the active session id and user token passed to the client when text or attachment messages +/// are sent. +package struct MessageSendCredentials { + + /// Current consultant session identifier. + package let sessionID: String + + /// Current consultant user token. + package let userToken: String + + package init(sessionID: String, userToken: String) { + self.sessionID = sessionID + self.userToken = userToken + } +} + +/// Local send unit created from prepared input attachments. +/// +/// Represents one image group, file group, or standalone text message in a send operation. +private struct OutgoingMessage { + + /// Text that should be sent with this message. + let text: String + + /// Message type used for the backend send request. + let messageType: MessageType + + /// Input attachments represented by this outgoing message. + let attachments: [InputAttachment] + + /// Backend send-message item payloads created from attachments. + let items: [ConsultantSendMessageItem] +} diff --git a/Sources/OnlineConsultant/ConsultantController/MessageStore.swift b/Sources/OnlineConsultant/ConsultantController/MessageStore.swift new file mode 100644 index 0000000..318db1b --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/MessageStore.swift @@ -0,0 +1,446 @@ +import Foundation + +/// Local store for chat messages known by `ConsultantController`. +/// +/// Owns the ordered message list and centralizes message mutations: replacing connect results, +/// merging loaded pages, applying realtime inserts/updates/deletes, creating temporary outgoing +/// messages, replacing them with backend acknowledgements, applying resolved file links, and updating +/// action contact-form messages. +package struct MessageStore { + + /// Messages currently known by the controller. + package private(set) var messages: [ConsultantMessage] = [] + + /// Resolves attachment quotes against loaded history, including newly loaded pages and file URLs. + package var messagesWithResolvedQuotes: [ConsultantMessage] { + let originals = Dictionary(messages.compactMap { message in + message.id.map { ($0.value, message) } + }, uniquingKeysWith: { _, latest in latest }) + return messages.map { message in + guard let quoteID = message.quoteID?.value, + quoteID != message.id?.value, + let original = originals[quoteID], + original.status != .deleted else { return message } + var result = message + if let items = original.items, !items.isEmpty, + original.messageType == .file || original.messageType == .image { + result.resolvedQuotePreview = .attachments( + items, isImage: original.messageType == .image, + text: message.quoteContent?.nilIfEmpty ?? original.content?.nilIfEmpty, + author: original.fromMe == true + ? ConsultantLocalization.messageQuoteAuthorYou.localized + : original.user?.displayName(isOutgoing: false) + ?? MessageUser.fallbackDisplayName(isOutgoing: false) + ) + } else if let text = message.quoteContent?.nilIfEmpty ?? original.content?.nilIfEmpty { + result.resolvedQuotePreview = .text(text) + } + return result + } + } + + /// Number of messages currently stored. + package var messageCount: Int { + messages.count + } + + /// Indicates whether the store has no messages. + package var isEmpty: Bool { + messages.isEmpty + } + + /// File item identifiers that still need resolved file URLs. + package var unresolvedFileLinkItemIDs: Set { + Set(messages.flatMap(\.unresolvedFileLinkItemIDs)) + } + + /// Number of unread incoming messages in the loaded message list. + package var unreadIncomingMessagesCount: Int { + messages.reduce(0) { count, message in + let isUnread = message.fromMe == false + && message.status != .seen + && message.status != .deleted + && !message.isVirtualSupportMessage + return count + (isUnread ? 1 : 0) + } + } + + /// Marks incoming backend messages with ids up to the provided id as seen. + package mutating func markIncomingMessagesSeen(upTo messageID: String) -> Bool { + guard let lastReadMessageID = UInt64(messageID) else { + return false + } + + var didUpdate = false + messages = messages.map { message in + guard + message.fromMe == false, + message.status != .seen, + message.status != .deleted, + let id = message.id?.value, + let numericID = UInt64(id), + numericID <= lastReadMessageID + else { + return message + } + + didUpdate = true + return message.applyingStatus(.seen) + } + + return didUpdate + } + + /// Latest known message update timestamp used as realtime reconnect watermark. + package var latestKnownUpdatedAt: String? { + messages + .compactMap { $0.updatedAt ?? $0.time } + .max() + } + + /// Returns a message with the provided identifier, if it is stored. + package func message(id: String) -> ConsultantMessage? { + messages.first { $0.id?.value == id } + } + + /// Removes every stored message. + package mutating func reset() { + messages = [] + } + + /// Replaces stored messages with a new array. + package mutating func replaceAll(_ messages: [ConsultantMessage]) { + self.messages = messages + } + + /// Appends messages to the end of the store. + package mutating func append(contentsOf messages: [ConsultantMessage]) { + self.messages.append(contentsOf: messages) + } + + /// Appends a local intro message when the current chat history is empty. + private mutating func appendIntroMessageIfNeeded(text: String?) -> Bool { + guard + isEmpty, + let introText = text?.trimmingCharacters(in: .whitespacesAndNewlines), + !introText.isEmpty + else { + return false + } + + messages.append(.introMessage(text: introText)) + return true + } + + /// Rebuilds local support messages from the current bootstrap configuration. + package mutating func refreshVirtualSupportMessages( + config: ConsultantConfig?, + user: ContactFormUser, + isOffline: Bool, + hasSubmittedOfflineForm: Bool + ) -> Bool { + let oldMessages = messages + messages.removeAll { $0.isVirtualSupportMessage } + + guard let config else { + return messages != oldMessages + } + + if isOffline, config.useOfflineMode { + let offlineText = resolveOfflineText(config) + messages.append( + .offlineMessage( + text: offlineText, + contactForm: makeOfflineContactForm( + config: config, + text: offlineText, + user: user, + isSubmitted: hasSubmittedOfflineForm + ) + ) + ) + } else { + _ = appendIntroMessageIfNeeded(text: config.introText) + } + + return messages != oldMessages + } + + /// Merges backend messages into the store by message identifier. + package mutating func merge(_ newMessages: [ConsultantMessage]) { + for message in newMessages { + guard let messageID = message.id?.value else { + appendBeforeVirtualSupportMessages(message) + continue + } + + if let index = messages.firstIndex(where: { $0.id?.value == messageID }) { + messages[index] = message.preservingResolvedFileLinks(from: messages[index]) + } else { + appendBeforeVirtualSupportMessages(message) + } + } + } + + /// Applies a realtime message insert, update, or delete event. + package mutating func applyRealtimeMessage(_ message: ConsultantMessage) { + guard let messageID = message.id?.value else { + appendBeforeVirtualSupportMessages(message) + return + } + if message.status == .deleted { + messages.removeAll { $0.id?.value == messageID } + return + } + if let index = messages.firstIndex(where: { $0.id?.value == messageID }) { + messages[index] = message.mergingMissingFields(from: messages[index]) + return + } + appendBeforeVirtualSupportMessages(message) + } + + /// Adds a local outgoing message and returns its temporary identifier. + package mutating func appendLocalOutgoingMessage( + _ text: String, + messageType: MessageType = .text, + items: [MessageItem]? = nil + ) -> String { + let id = "_local_\(UUID().uuidString)" + let timestamp = DateParser.string(from: Date()) + let message = ConsultantMessage( + id: FlexibleID(id), + content: text.isEmpty ? nil : text, + items: items, + time: timestamp, + updatedAt: timestamp, + user: MessageUser(), + fromMe: true, + messageType: messageType, + status: .sending + ) + + appendBeforeVirtualSupportMessages(message) + + return id + } + + /// Replaces a temporary local message with backend acknowledgement data. + package mutating func replaceLocalMessage( + id: String, + sentMessage: ConsultantSentMessage + ) -> Bool { + guard let index = messages.firstIndex(where: { $0.id?.value == id }) else { + return false + } + + let localMessage = messages[index] + let acknowledgedMessage = ConsultantMessage( + id: FlexibleID(sentMessage.id), + content: localMessage.content, + product: localMessage.product, + order: localMessage.order, + items: localMessage.items, + quoteContent: localMessage.quoteContent, + quoteID: localMessage.quoteID, + time: sentMessage.time, + updatedAt: sentMessage.updatedAt ?? sentMessage.time, + user: localMessage.user, + fromMe: localMessage.fromMe ?? true, + messageType: localMessage.messageType ?? .text, + status: sentMessage.status, + contactForm: localMessage.contactForm + ) + + if let duplicateIndex = messages.firstIndex(where: { $0.id?.value == sentMessage.id }) { + let mergedMessage = messages[duplicateIndex].mergingMissingFields(from: acknowledgedMessage) + + if duplicateIndex < index { + messages.remove(at: duplicateIndex) + messages[index - 1] = mergedMessage + } else { + messages[index] = mergedMessage + messages.remove(at: duplicateIndex) + } + } else { + messages[index] = acknowledgedMessage + } + + return true + } + + /// Updates delivery status for a temporary local message. + package mutating func updateLocalMessageStatus( + id: String, + status: MessageStatus + ) -> Bool { + guard let index = messages.firstIndex(where: { $0.id?.value == id }) else { + return false + } + + let message = messages[index] + messages[index] = ConsultantMessage( + id: message.id, + content: message.content, + product: message.product, + order: message.order, + items: message.items, + quoteContent: message.quoteContent, + quoteID: message.quoteID, + time: message.time, + updatedAt: DateParser.string(from: Date()), + user: message.user, + fromMe: message.fromMe, + messageType: message.messageType, + status: status, + contactForm: message.contactForm + ) + + return true + } + + /// Applies resolved backend file links to matching message items. + package mutating func applyFileLinks(_ links: [String: String]) -> Bool { + let updatedMessages = messages.map { + links.reduce($0) { message, link in + message.applyingFileLink( + itemID: link.key, + url: link.value + ) + } + } + + guard updatedMessages != messages else { + return false + } + + messages = updatedMessages + return true + } + + /// Marks an action contact-form message as submitted. + package mutating func markActionContactFormSubmitted(actionID: FlexibleID?) -> Bool { + guard let actionID = actionID?.value else { + return false + } + + guard let index = messages.firstIndex(where: { message in + message.contactForm?.actionID?.value == actionID + }) else { + return false + } + + let message = messages[index] + guard message.contactForm?.isSubmitted != true else { + return false + } + + messages[index] = message.applyingContactForm(message.contactForm?.submitted()) + return true + } + + /// Marks the local offline contact-form message as submitted. + package mutating func markOfflineContactFormSubmitted() -> Bool { + updateOfflineContactForm { $0.submitted() } + } + + /// Updates an action contact-form message with a transformed form value. + package mutating func updateActionContactForm( + actionID: FlexibleID?, + transform: (ContactForm) -> ContactForm + ) -> Bool { + guard let actionID = actionID?.value else { + return false + } + + return updateContactForm(matching: { message in + message.contactForm?.actionID?.value == actionID + }, transform: transform) + } + + /// Updates the local offline contact-form message with a transformed form value. + package mutating func updateOfflineContactForm( + transform: (ContactForm) -> ContactForm + ) -> Bool { + updateContactForm( + matching: { $0.isOfflineMessage }, + requiredKind: .offline, + transform: transform + ) + } + + private mutating func updateContactForm( + matching matches: (ConsultantMessage) -> Bool, + requiredKind: ContactFormKind? = nil, + transform: (ContactForm) -> ContactForm + ) -> Bool { + guard let index = messages.firstIndex(where: matches), + let contactForm = messages[index].contactForm else { + return false + } + if let requiredKind, contactForm.kind != requiredKind { + return false + } + let updatedContactForm = transform(contactForm) + guard updatedContactForm != contactForm else { + return false + } + + messages[index] = messages[index].applyingContactForm(updatedContactForm) + return true + } + + private func makeOfflineContactForm( + config: ConsultantConfig, + text: String, + user: ContactFormUser, + isSubmitted: Bool + ) -> ContactForm? { + guard + let fields = config.offlineForm?.fields, + !fields.isEmpty + else { + return nil + } + + return ContactForm( + text: text, + resultText: resolveOfflineFormResultText(config), + kind: .offline, + fields: fields, + prefill: ContactFormPrefill( + name: user.name, + email: user.email, + phone: user.phone + ), + isSubmitted: isSubmitted + ) + } + + private func resolveOfflineText(_ config: ConsultantConfig) -> String { + config.offlineText?.nilIfEmpty ?? ConsultantLocalization.offlineDefaultText.localized + } + + private func resolveOfflineFormResultText(_ config: ConsultantConfig) -> String { + config.offlineFormResultText?.nilIfEmpty + ?? ConsultantLocalization.offlineFormDefaultResultText.localized + } + + private mutating func appendBeforeVirtualSupportMessages(_ message: ConsultantMessage) { + removeIntroMessage() + + guard let firstVirtualSupportIndex = messages.firstIndex(where: { $0.isVirtualSupportMessage }) else { + messages.append(message) + return + } + + messages.insert(message, at: firstVirtualSupportIndex) + } + + private mutating func removeIntroMessage() { + messages.removeAll { $0.isIntroMessage } + } + + package init(messages: [ConsultantMessage] = []) { + self.messages = messages + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/MessagesPage.swift b/Sources/OnlineConsultant/ConsultantController/MessagesPage.swift new file mode 100644 index 0000000..dffdbd7 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/MessagesPage.swift @@ -0,0 +1,24 @@ +// +// MessagesPage.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 23.07.2026. +// + +/// Result returned after loading an older page of consultant messages. +/// +/// Carries the messages returned by the backend for the requested offset and a pagination hint that +/// tells UI code whether another older page is likely available. + package struct MessagesPage: Equatable, Sendable { + + /// Messages returned by the backend for the requested page. + package let messages: [ConsultantMessage] + /// Indicates whether another older page is likely available. + package let hasMoreMessages: Bool + + /// Creates a messages page result. + package init(messages: [ConsultantMessage], hasMoreMessages: Bool) { + self.messages = messages + self.hasMoreMessages = hasMoreMessages + } +} diff --git a/Sources/OnlineConsultant/ConsultantController/OnlineConsultant.swift b/Sources/OnlineConsultant/ConsultantController/OnlineConsultant.swift new file mode 100644 index 0000000..6f0a0d3 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantController/OnlineConsultant.swift @@ -0,0 +1,1368 @@ +import Core +import Foundation + +/// Main controller facade for the online consultant session. +/// +/// Tracks the shared chat session, its connection state, unread messages, configuration, and file cache. +@MainActor +public final class OnlineConsultantController { + + /// Current connection state; observe changes through `onStateChange`. + public private(set) var state: ControllerState = .idle + /// Calls the handler with the current state when assigned, then whenever it changes. + public var onStateChange: ((ControllerState) -> Void)? { + didSet { onStateChange?(state) } + } + /// Latest connect response received from the backend. + package private(set) var connectResponse: ConsultantConnectResponse? + /// Messages currently known by the controller. + package var messages: [ConsultantMessage] { + messageStore.messagesWithResolvedQuotes + } + /// consultant configuration received from the backend. + package private(set) var consultantConfig: ConsultantConfig? + /// Indicates whether the consultant is currently offline. + package private(set) var isOffline = false + /// Number of unread incoming messages in the loaded history; zero when the counter is disabled. + /// Observe changes through `onUnreadIncomingMessagesCountChange`. + public private(set) var unreadIncomingMessagesCount = 0 + /// Calls the handler with the current unread count when assigned, then whenever it changes. + public var onUnreadIncomingMessagesCountChange: ((Int) -> Void)? { + didSet { onUnreadIncomingMessagesCountChange?(unreadIncomingMessagesCount) } + } + /// Indicates whether older messages can be requested. + package private(set) var canLoadOlderMessages = false + /// Current contact-form presentation state. + package private(set) var contactFormPresentation: ContactFormPresentation = .hidden + /// Attachments prepared in the message input but not sent yet. + package var inputAttachments: [InputAttachment] { + inputAttachmentStore.attachments + } + + /// Indicates whether all prepared input attachments are ready to be sent. + package var canSendInputAttachments: Bool { + inputAttachmentStore.canSend + } + + private let client: OnlineConsultantClient + private var startTask: Task? + private var realtimeTask: Task? + private let applicationLifecycle: ConsultantApplicationLifecycle + private var realtimeSessionGeneration: UUID? + private var realtimeGeneration = UUID() + private var brandingLogoLoadTask: ImageLoadTask? + private var connectionGeneration = UUID() + private var isAnonymousSession = true + private let contactFormActionCoordinator: ContactFormActionCoordinator + private let messageReadCoordinator: MessageReadCoordinator + private let messageSender: MessageSender + private var contactFormState = ContactFormState() + private var inputAttachmentStore = InputAttachmentStore() + private var messageStore = MessageStore() + private let fileCache: ConsultantFileCache + private let cachePolicy: OnlineConsultantCachePolicy + private let remoteFileCacheLoader: ConsultantRemoteFileCacheLoading + private let messageFileLinkCoordinator: MessageFileLinkCoordinator + package private(set) var internalState: ControllerInternalState = .idle + private let eventBroadcaster = AsyncBroadcaster() + private let stateBroadcaster = AsyncBroadcaster() + private let internalStateBroadcaster = AsyncBroadcaster() + private let messageBroadcaster = AsyncBroadcaster<[ConsultantMessage]>() + private let inputAttachmentBroadcaster = AsyncBroadcaster<[InputAttachment]>() + + package convenience init( + config: OnlineConsultantConfig, + sessionRuntime: SessionRuntime + ) { + self.init( + client: OnlineConsultantClient( + config: config, + sessionRuntime: sessionRuntime + ), + cachePolicy: config.cachePolicy + ) + } + + package init( + client: OnlineConsultantClient, + fileCache: ConsultantFileCache = ConsultantFileCache(), + remoteFileCacheLoader: ConsultantRemoteFileCacheLoading? = nil, + cachePolicy: OnlineConsultantCachePolicy? = nil, + applicationLifecycle: ConsultantApplicationLifecycle? = nil + ) { + self.client = client + self.applicationLifecycle = applicationLifecycle ?? ConsultantApplicationLifecycle() + self.contactFormActionCoordinator = ContactFormActionCoordinator(client: client) + self.messageReadCoordinator = MessageReadCoordinator(client: client) + self.messageSender = MessageSender(client: client) + self.fileCache = fileCache + self.cachePolicy = cachePolicy ?? client.config.cachePolicy + self.remoteFileCacheLoader = remoteFileCacheLoader ?? RemoteFileCacheLoader( + fileCache: fileCache + ) + self.messageFileLinkCoordinator = MessageFileLinkCoordinator( + client: client, + fileCache: fileCache, + remoteFileCacheLoader: self.remoteFileCacheLoader, + cachePolicy: self.cachePolicy + ) + + trimCacheIfNeeded() + self.applicationLifecycle.onForegroundChange = { [weak self] isInForeground in + if isInForeground { + self?.resumeRealtimeEventsIfNeeded() + } else { + self?.pauseRealtimeEvents() + } + } + } + + deinit { + startTask?.cancel() + realtimeTask?.cancel() + brandingLogoLoadTask?.cancel() + Task { @MainActor [messageFileLinkCoordinator] in + messageFileLinkCoordinator.cancel() + } + Task { @MainActor [messageReadCoordinator] in + messageReadCoordinator.reset() + } + eventBroadcaster.finish() + stateBroadcaster.finish() + internalStateBroadcaster.finish() + messageBroadcaster.finish() + inputAttachmentBroadcaster.finish() + } +} + +// MARK: - Observation + +/// Observation API for public connection and unread-message state and internal UI streams. +extension OnlineConsultantController { + + /// Returns a stream of controller events such as connection, errors, and realtime updates. + package func events() -> AsyncStream { + eventBroadcaster.stream() + } + + /// Returns a stream of connection state changes. The current state is emitted immediately. + package func stateUpdates() -> AsyncStream { + stateBroadcaster.stream(initialValue: state) + } + + /// Returns a stream of lifecycle states for SDK UI. The current state is emitted immediately. + package func internalStateUpdates() -> AsyncStream { + internalStateBroadcaster.stream(initialValue: internalState) + } + + /// Returns a stream of message-list changes. The current message list is emitted immediately. + package func messagesUpdates() -> AsyncStream<[ConsultantMessage]> { + messageBroadcaster.stream(initialValue: messages) + } + + /// Chat title received from backend `config.name`. + public var name: String? { + consultantConfig?.name + } + + /// External consultant link settings received from the backend. + public var linkSettings: LinkSettings { + consultantConfig?.linkSettings ?? LinkSettings() + } + + /// Returns a stream of input-attachment changes. The current list is emitted immediately. + package func inputAttachmentUpdates() -> AsyncStream<[InputAttachment]> { + inputAttachmentBroadcaster.stream(initialValue: inputAttachments) + } +} + +// MARK: - File Cache + +/// File-cache API used by host applications and preview helpers to inspect, trim, clear, and populate +/// the SDK-managed local file cache. +extension OnlineConsultantController { + + /// Returns the local cached URL for a file id, if the file is already cached. + package func cachedFileURL(fileID: String) -> URL? { + fileCache.cachedFileURL(fileID: fileID) + } + + /// Downloads a remote file into the local cache and returns its local URL. + package func cacheRemoteFile( + fileID: String, + remoteURL: URL + ) async throws -> URL { + let localURL = try await remoteFileCacheLoader.cacheRemoteFile( + fileID: fileID, + remoteURL: remoteURL + ) + trimCacheIfNeeded() + return localURL + } + + /// Returns current local file-cache statistics. + public func cacheStatistics() -> ConsultantCacheStatistics { + fileCache.statistics() + } + + /// Applies the configured cache policy without blocking the main actor. + public func trimCache() async throws -> ConsultantCacheStatistics { + let fileCache = self.fileCache + let cachePolicy = self.cachePolicy + do { + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + continuation.resume(returning: try fileCache.trim(policy: cachePolicy)) + } catch { + continuation.resume(throwing: ConsultantError.cacheError(from: error)) + } + } + } + } catch { + reportError(error) + throw error + } + } + + /// Clears all locally cached files without blocking the main actor. + public func clearCache() async throws { + messageFileLinkCoordinator.cancelRemoteCaching() + let fileCache = self.fileCache + do { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global(qos: .utility).async { + do { + try fileCache.removeAll() + continuation.resume(returning: ()) + } catch { + continuation.resume(throwing: ConsultantError.cacheError(from: error)) + } + } + } + } catch { + reportError(error) + throw error + } + } +} + +/// Completion-based file-cache API. +public extension OnlineConsultantController { + + /// Applies the configured cache policy and returns statistics in the completion. + /// + /// - Parameters: + /// - completion: Receives the result of the operation. + func trimCache( + completion: (Result) -> Void + ) { + do { + completion(.success(try fileCache.trim(policy: cachePolicy))) + } catch { + let publicError = ConsultantError.cacheError(from: error) + reportError(publicError) + completion(.failure(publicError)) + } + } + + /// Clears all locally cached files managed by the SDK. + /// + /// - Parameters: + /// - completion: Receives the result of the operation. + func clearCache(completion: (Result) -> Void) { + messageFileLinkCoordinator.cancelRemoteCaching() + do { + try fileCache.removeAll() + completion(.success(())) + } catch { + let publicError = ConsultantError.cacheError(from: error) + reportError(publicError) + completion(.failure(publicError)) + } + } +} + +// MARK: - Connection Lifecycle + +/// Internal lifecycle used by the shared SDK session and the built-in UI retry action. +package extension OnlineConsultantController { + + func start(user: ConnectUser = ConnectUser()) { + startTask?.cancel() + stopRealtimeEvents() + brandingLogoLoadTask?.cancel() + brandingLogoLoadTask = nil + cancelFileLinkTasks() + messageReadCoordinator.reset() + let generation = nextConnectionGeneration() + + startTask = Task { [weak self] in + guard let self else { return } + + do { + _ = try await performConnect( + user: user, + generation: generation + ) + } catch { + guard isCurrentConnectionGeneration(generation), !Task.isCancelled else { return } + if case .failed = state { return } + applyFailure(error) + } + } + } + + private func stop() { + startTask?.cancel() + startTask = nil + stopRealtimeEvents() + brandingLogoLoadTask?.cancel() + brandingLogoLoadTask = nil + cancelFileLinkTasks() + messageReadCoordinator.reset() + nextConnectionGeneration() + setConnectionState(.idle) + } + + private func resetSessionState() { + let oldUnreadIncomingMessagesCount = unreadIncomingMessagesCount + connectResponse = nil + isAnonymousSession = true + messageStore.reset() + consultantConfig = nil + isOffline = false + unreadIncomingMessagesCount = 0 + canLoadOlderMessages = false + contactFormActionCoordinator.reset() + contactFormState.reset() + contactFormPresentation = .hidden + _ = inputAttachmentStore.removeAll() + emitMessages() + emitUnreadIncomingMessagesCountIfNeeded(oldValue: oldUnreadIncomingMessagesCount) + emitInputAttachments() + } +} + +// MARK: - Shared Session Lifecycle + +extension OnlineConsultantController: SessionLifecycleListener { + + nonisolated package func sessionDidConnect( + credentials: SessionCredentials, + pageContext _: ConnectPageContext, + user: ConnectUser, + response _: ConnectResponse, + customerIdentity _: CrmSdkCustomerIdentity? + ) { + Task { @MainActor [weak self] in + self?.start(user: user) + } + } + + nonisolated package func sessionDidDisconnect() { + Task { @MainActor [weak self] in + guard let self else { return } + stop() + resetSessionState() + } + } +} + +// MARK: - Input Attachments + +/// Internal input-attachment API for creating upload placeholders, uploading attachment data, +/// marking failures, and removing prepared attachments. +package extension OnlineConsultantController { + + /// Adds an attachment placeholder to the input queue. + @discardableResult + func addInputAttachment( + fileName: String, + contentType: String = "application/octet-stream", + previewData: Data? = nil, + messageType: String = "file" + ) -> InputAttachment { + let attachment = inputAttachmentStore.add( + fileName: fileName, + contentType: contentType, + previewData: previewData, + messageType: messageType + ) + emitInputAttachments() + return attachment + } + + /// Uploads an existing input attachment placeholder to the backend. + @discardableResult + func uploadInputAttachment( + id: UUID, + data: Data, + contentType: String, + previewData: Data? = nil + ) async throws -> InputAttachment { + let credentials = try currentCredentials() + + updateInputAttachment( + id: id, + status: .uploading + ) + + if let previewData { + updateInputAttachmentPreviewData( + id: id, + previewData: previewData + ) + } + + guard let pendingAttachment = inputAttachmentStore.attachment(id: id) else { + throw ConsultantControllerError.inputAttachmentRemoved + } + + do { + let response = try await client.uploadFile( + data: data, + contentType: contentType, + session: credentials.sessionID, + userToken: credentials.userToken + ) + + guard let fileID = response.id else { + throw ConsultantControllerError.missingUploadedFileID + } + + do { + try fileCache.store( + data: data, + fileID: fileID.value, + contentType: contentType + ) + } catch { + reportError(ConsultantError.cacheError(from: error)) + } + trimCacheIfNeeded() + + let uploadedAttachment = updateInputAttachment( + id: id, + status: .uploaded(fileID: fileID) + ) ?? pendingAttachment.updatingStatus(.uploaded(fileID: fileID)) + + return uploadedAttachment + } catch { + updateInputAttachment( + id: id, + status: .failed(message: error.localizedDescription) + ) + reportError(error) + throw error + } + } + + /// Marks an input attachment as failed. + func failInputAttachment( + id: UUID, + message: String + ) { + updateInputAttachment( + id: id, + status: .failed(message: message) + ) + } + + /// Removes an attachment from the input queue. + func removeInputAttachment(id: UUID) { + guard inputAttachmentStore.remove(id: id) else { + return + } + + emitInputAttachments() + } + + private func removeInputAttachments(ids: [UUID]) { + guard inputAttachmentStore.remove(ids: ids) else { + return + } + + emitInputAttachments() + } +} + +// MARK: - Message Sending + +/// Internal message sending API for visitor text messages and messages with already uploaded input +/// attachments. +package extension OnlineConsultantController { + + /// Retries a failed outgoing message. Repeated requests while it is sending are ignored. + func retryMessage(id: String) async throws { + guard let message = messageStore.message(id: id), message.canRetrySending else { return } + let credentials = try currentCredentials() + try await messageSender.retryMessage( + message, + credentials: MessageSendCredentials( + sessionID: credentials.sessionID, + userToken: credentials.userToken + ), + callbacks: messageSenderCallbacks() + ) + } + + /// Sends a visitor text message. + func sendMessage(_ text: String) async throws { + let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !messageText.isEmpty else { return } + + let credentials = try currentCredentials() + try await messageSender.sendTextMessage( + messageText, + credentials: MessageSendCredentials( + sessionID: credentials.sessionID, + userToken: credentials.userToken + ), + callbacks: messageSenderCallbacks() + ) + } + + /// Sends visitor text together with already uploaded input attachments. + func sendMessage( + _ text: String, + attachments: [InputAttachment] + ) async throws { + let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !attachments.isEmpty else { + try await sendMessage(messageText) + return + } + + let credentials = try currentCredentials() + try await messageSender.sendAttachmentMessages( + text: messageText, + attachments: attachments, + credentials: MessageSendCredentials( + sessionID: credentials.sessionID, + userToken: credentials.userToken + ), + callbacks: messageSenderCallbacks() + ) + } +} + +// MARK: - Contact Form Submission + +/// Internal contact-form submission API and related submit result handling. +package extension OnlineConsultantController { + + /// Submits the currently displayed contact form. + func submitContactForm(_ data: ContactFormSubmitData) async throws { + switch data.kind { + case .contact: + try await submitContactFormData(data) + case .offline: + try await submitOfflineFormData(data) + } + } + + private func submitContactFormData(_ data: ContactFormSubmitData) async throws { + clearActionContactFormSubmitError(actionID: data.actionID) + + do { + let credentials = try currentCredentials() + try await client.sendContactForm( + data, + session: credentials.sessionID, + userToken: credentials.userToken, + actionID: data.actionID?.value + ) + emit(.contactFormSubmitted(data)) + applyContactFormSubmitData(data) + markActionContactFormSubmitted(actionID: data.actionID) + } catch { + markActionContactFormSubmitError( + actionID: data.actionID, + message: error.localizedDescription, + submittedFields: data.submittedFields + ) + reportError(error) + throw error + } + } + + private func submitOfflineFormData(_ data: ContactFormSubmitData) async throws { + clearOfflineContactFormSubmitError() + + do { + let credentials = try currentCredentials() + try await client.sendOfflineForm( + data, + session: credentials.sessionID, + userToken: credentials.userToken + ) + emit(.contactFormSubmitted(data)) + applyOfflineFormSubmitData(data) + markOfflineContactFormSubmitted() + } catch { + markOfflineContactFormSubmitError( + message: error.localizedDescription, + submittedFields: data.submittedFields + ) + reportError(error) + throw error + } + } +} + +// MARK: - Message Loading + +/// Internal message history loading API for reloading the latest page and requesting older pages. +package extension OnlineConsultantController { + + /// Loads older messages using the current message count as an offset. + @discardableResult + func loadOlderMessages() async throws -> MessagesPage { + let credentials = try currentCredentials() + let generation = connectionGeneration + let loadedMessages = try await client.loadMessages( + session: credentials.sessionID, + userToken: credentials.userToken, + offset: messageStore.messageCount + ) + try Task.checkCancellation() + guard isCurrentConnectionGeneration(generation) else { throw CancellationError() } + canLoadOlderMessages = loadedMessages.count >= client.messagesPageSize + mergeMessages(loadedMessages) + return MessagesPage( + messages: loadedMessages, + hasMoreMessages: canLoadOlderMessages + ) + } +} + +// MARK: - Connection Flow + +/// Internal connect flow that calls the backend, validates credentials, applies bootstrap state, +/// loads additional initial messages, applies contact-form actions, and subscribes to realtime events. +package extension OnlineConsultantController { + + private func performConnect( + user: ConnectUser, + generation: UUID + ) async throws -> ConsultantConnectResponse { + setConnectionState(.connecting) + + do { + let response = try await client.connect() + try ensureCurrentConnectionGeneration(generation) + let isAnonymous = await client.isAnonymousSession() + try ensureCurrentConnectionGeneration(generation) + isAnonymousSession = isAnonymous + try applyConnectResponse(response, requestedUser: user, publishesReady: false) + brandingLogoLoadTask = FooterBrandingLogoLoader.prefetch( + domainConfig: response.config?.domainConfig, + theme: ConsultantTheme(config: response.config) + ) + await loadInitialMessagesIfNeeded( + connectResponse: response, + generation: generation + ) + try ensureCurrentConnectionGeneration(generation) + applyInitialContactFormActionIfNeeded(response.actions, generation: generation) + refreshVirtualSupportMessages() + publishReadyState() + emit(.connected(response)) + emitMessages() + subscribeRealtimeEvents(generation: generation) + return response + } catch { + guard isCurrentConnectionGeneration(generation), !Task.isCancelled else { throw error } + applyFailure(error) + throw error + } + } + + private func applyConnectResponse( + _ response: ConsultantConnectResponse, + requestedUser: ConnectUser, + publishesReady: Bool = true + ) throws { + guard response.user.token != nil, response.session.id?.value != nil else { + throw ConsultantControllerError.missingCredentials + } + + guard response.behaviour?.widget != false else { + throw ConsultantControllerError.unavailable + } + + connectResponse = response + contactFormState.applyConnectedUser( + response.user, + fallbackUser: requestedUser + ) + consultantConfig = response.config + isOffline = response.behaviour?.isOffline == true + canLoadOlderMessages = !response.messages.isEmpty + applyMessages(response.messages, emitsEvent: false, publishesReady: publishesReady) + + if publishesReady { + emit(.connected(response)) + emitMessages() + } + } + + private func loadInitialMessagesIfNeeded( + connectResponse response: ConsultantConnectResponse, + generation: UUID + ) async { + let alreadyLoadedCount = response.messages.count + let initialMessagesLimit = client.initialMessagesLimit + + guard initialMessagesLimit > alreadyLoadedCount else { + canLoadOlderMessages = alreadyLoadedCount > 0 + return + } + + guard let userToken = response.user.token, + let sessionID = response.session.id?.value else { + canLoadOlderMessages = false + return + } + + let requestLimit = initialMessagesLimit - alreadyLoadedCount + + do { + let loadedMessages = try await client.loadMessages( + session: sessionID, + userToken: userToken, + limit: requestLimit, + offset: alreadyLoadedCount + ) + guard isCurrentConnectionGeneration(generation), !Task.isCancelled else { + return + } + canLoadOlderMessages = loadedMessages.count >= requestLimit + mergeMessages(loadedMessages, emitsEvent: false, publishesReady: false) + } catch { + guard isCurrentConnectionGeneration(generation), !Task.isCancelled else { return } + canLoadOlderMessages = alreadyLoadedCount > 0 + reportError(error) + } + } +} + +// MARK: - Contact-Form Actions + +/// Displays contact-form actions and refreshes local intro and offline messages. +package extension OnlineConsultantController { + + private func applyInitialContactFormActionIfNeeded( + _ actions: [SiteAction], + generation: UUID + ) { + guard let contactFormMessage = contactFormActionCoordinator.initialContactFormMessageIfNeeded( + actions: actions, + contactFormUser: contactFormState.user, + messagesAreEmpty: messageStore.isEmpty, + generation: generation, + isCurrentGeneration: { [weak self] generation in + self?.isCurrentConnectionGeneration(generation) == true + }, + currentCredentials: { [weak self] in + guard let self else { + throw ConsultantControllerError.missingCredentials + } + + return try self.currentCredentials() + }, + handleError: { [weak self] error in + self?.reportError(error) + } + ) else { return } + + messageStore.append(contentsOf: [contactFormMessage]) + recalculateUnreadIncomingMessagesCount() + recalculateContactFormPresentation() + resolveMissingFileLinks() + } + + @discardableResult + private func refreshVirtualSupportMessages() -> Bool { + guard messageStore.refreshVirtualSupportMessages( + config: consultantConfig, + user: contactFormState.user, + isOffline: isOffline, + hasSubmittedOfflineForm: contactFormState.hasSubmittedForm(kind: .offline) + ) else { + return false + } + + recalculateUnreadIncomingMessagesCount() + recalculateContactFormPresentation() + return true + } +} + +// MARK: - Realtime + +/// Internal realtime handling for WebSocket subscription, reconnects, decoded event application, and +/// incoming-message notifications. +package extension OnlineConsultantController { + + private func subscribeRealtimeEvents(generation: UUID) { + pauseRealtimeEvents() + realtimeSessionGeneration = generation + resumeRealtimeEventsIfNeeded() + } + + private func stopRealtimeEvents() { + realtimeSessionGeneration = nil + pauseRealtimeEvents() + } + + private func pauseRealtimeEvents() { + realtimeGeneration = UUID() + realtimeTask?.cancel() + realtimeTask = nil + } + + private func resumeRealtimeEventsIfNeeded() { + guard applicationLifecycle.isInForeground, + realtimeTask == nil, + let generation = realtimeSessionGeneration, + isCurrentConnectionGeneration(generation) else { return } + let realtimeGeneration = UUID() + self.realtimeGeneration = realtimeGeneration + realtimeTask = Task { [weak self] in + guard let self else { return } + defer { + if self.realtimeGeneration == realtimeGeneration { realtimeTask = nil } + } + + while isCurrentConnectionGeneration(generation), + self.realtimeGeneration == realtimeGeneration, + applicationLifecycle.isInForeground, !Task.isCancelled { + do { + let credentials = try currentCredentials() + let stream = client.subscribeRealtimeEvents( + session: credentials.sessionID, + userToken: credentials.userToken, + updatedAt: messageStore.latestKnownUpdatedAt + ) + + for try await event in stream { + guard isCurrentConnectionGeneration(generation), + self.realtimeGeneration == realtimeGeneration, + applicationLifecycle.isInForeground, !Task.isCancelled else { + return + } + applyRealtimeEvent(event) + } + + return + } catch { + guard isCurrentConnectionGeneration(generation), + self.realtimeGeneration == realtimeGeneration, + applicationLifecycle.isInForeground, !Task.isCancelled else { return } + + reportError(error) + + guard client.config.autoReconnectWebSocket else { + return + } + + do { + try await Task.sleep( + nanoseconds: UInt64(client.config.webSocketReconnectDelay * 1_000_000_000) + ) + } catch { return } + } + } + } + } + + private func applyRealtimeEvent(_ event: ConsultantRealtimeEvent) { + emit(.realtimeEvent(event)) + + switch event { + case .message(let message): + messageStore.applyRealtimeMessage(message) + publishMessagesChanged() + case .offlineStatus(let status): + isOffline = status.isOffline + let didRefreshMessages = refreshVirtualSupportMessages() + if !didRefreshMessages { + recalculateContactFormPresentation() + } + publishReadyState() + if didRefreshMessages { + emitMessages() + } + emit(.offlineStatusChanged(status.isOffline)) + case .unknown: + break + } + } +} + +// MARK: - Message State + +/// Internal message-state mutations shared by connect, history loading, realtime events, outgoing +/// sends, file-link updates, and contact-form updates. +package extension OnlineConsultantController { + + private func applyMessages( + _ value: [ConsultantMessage], + emitsEvent: Bool = true, + publishesReady: Bool = true + ) { + messageStore.replaceAll(value) + publishMessagesChanged( + emitsEvent: emitsEvent, + publishesReady: publishesReady, + fileLinkResolution: .beforeEmission + ) + } + + private func mergeMessages( + _ value: [ConsultantMessage], + emitsEvent: Bool = true, + publishesReady: Bool = true + ) { + messageStore.merge(value) + publishMessagesChanged( + emitsEvent: emitsEvent, + publishesReady: publishesReady, + fileLinkResolution: .beforeEmission + ) + } + + private func appendLocalOutgoingMessage( + _ text: String, + messageType: MessageType = .text, + items: [MessageItem]? = nil + ) -> String { + let id = messageStore.appendLocalOutgoingMessage( + text, + messageType: messageType, + items: items + ) + publishMessagesChanged() + + return id + } + + private func replaceLocalMessage( + id: String, + sentMessage: ConsultantSentMessage + ) { + if messageStore.replaceLocalMessage(id: id, sentMessage: sentMessage) { + publishMessagesChanged() + } + } + + private func updateLocalMessageStatus(id: String, status: MessageStatus) { + if messageStore.updateLocalMessageStatus(id: id, status: status) { + publishMessagesChanged() + } + } + + /// Marks incoming messages up to a backend message id as read locally and schedules backend sync. + @discardableResult + func markIncomingMessagesRead(upTo messageID: String) -> Bool { + let credentials: (sessionID: String, userToken: String) + do { + credentials = try currentCredentials() + } catch { + reportError(error) + return false + } + + guard messageStore.markIncomingMessagesSeen(upTo: messageID) else { + return false + } + + publishMessagesChanged(fileLinkResolution: .none) + messageReadCoordinator.schedule( + messageID: messageID, + session: credentials.sessionID, + userToken: credentials.userToken, + handleError: { [weak self] error in + self?.reportError(error) + } + ) + return true + } + + private func messageSenderCallbacks() -> MessageSenderCallbacks { + .init( + appendLocalMessage: { [weak self] text, messageType, items in + self?.appendLocalOutgoingMessage( + text, + messageType: messageType, + items: items + ) ?? "" + }, + removeInputAttachments: { [weak self] attachmentIDs in + self?.removeInputAttachments(ids: attachmentIDs) + }, + replaceLocalMessage: { [weak self] localMessageID, sentMessage in + self?.replaceLocalMessage( + id: localMessageID, + sentMessage: sentMessage + ) + }, + updateLocalMessageStatus: { [weak self] localMessageID, status in + self?.updateLocalMessageStatus( + id: localMessageID, + status: status + ) + }, + handleError: { [weak self] error in + self?.reportError(error) + } + ) + } + + private enum FileLinkResolutionTiming { + case beforeEmission + case afterEmission + case none + } + + private func publishMessagesChanged( + emitsEvent: Bool = true, + publishesReady: Bool = true, + fileLinkResolution: FileLinkResolutionTiming = .afterEmission + ) { + recalculateUnreadIncomingMessagesCount() + recalculateContactFormPresentation() + if publishesReady { + publishReadyState() + } + if case .beforeEmission = fileLinkResolution { + resolveMissingFileLinks() + } + if emitsEvent { + emitMessages() + } + if case .afterEmission = fileLinkResolution { + resolveMissingFileLinks() + } + } +} + +// MARK: - File Links + +/// Internal file-link resolution that applies cached URLs, requests missing backend links, and +/// coordinates background remote-file caching. +package extension OnlineConsultantController { + + private func resolveMissingFileLinks() { + let credentials = try? currentCredentials() + messageFileLinkCoordinator.resolveMissingFileLinks( + unresolvedFileIDs: messageStore.unresolvedFileLinkItemIDs, + credentials: credentials.map { + MessageFileLinkCredentials( + sessionID: $0.sessionID, + userToken: $0.userToken + ) + }, + applyFileLinks: { [weak self] links in + self?.applyFileLinks(links) + }, + handleError: { [weak self] error in + self?.reportError(error) + } + ) + } + + private func trimCacheIfNeeded() { + guard cachePolicy.automaticCleanup else { + return + } + + do { + _ = try fileCache.trim(policy: cachePolicy) + } catch { + reportError(ConsultantError.cacheError(from: error)) + } + } + + private func applyFileLinks(_ links: [String: String]) { + guard messageStore.applyFileLinks(links) else { + return + } + + publishMessagesChanged(fileLinkResolution: .none) + } + + private func cancelFileLinkTasks() { + messageFileLinkCoordinator.cancel() + } +} + +// MARK: - Unread State + +/// Internal unread incoming-message badge calculation and publishing. +package extension OnlineConsultantController { + + private func recalculateUnreadIncomingMessagesCount() { + let oldValue = unreadIncomingMessagesCount + + guard connectResponse?.config?.useUnreadCounter != false else { + unreadIncomingMessagesCount = 0 + emitUnreadIncomingMessagesCountIfNeeded(oldValue: oldValue) + return + } + + unreadIncomingMessagesCount = messageStore.unreadIncomingMessagesCount + emitUnreadIncomingMessagesCountIfNeeded(oldValue: oldValue) + } +} + +// MARK: - Contact Form State + +/// Internal contact-form presentation state, submit-state updates, and per-message form error +/// handling. +package extension OnlineConsultantController { + + private func recalculateContactFormPresentation() { + contactFormPresentation = contactFormState.presentation( + response: connectResponse, + messages: messages, + isOffline: isOffline + ) + } + + private func applyContactFormSubmitData(_ data: ContactFormSubmitData) { + contactFormState.applySubmitData(data) + connectResponse = connectResponse?.applyingContactFormSubmitData(data) + recalculateContactFormPresentation() + publishReadyState() + } + + private func applyOfflineFormSubmitData(_ data: ContactFormSubmitData) { + contactFormState.applySubmitData(data, kind: .offline) + connectResponse = connectResponse?.applyingContactFormSubmitData(data) + recalculateContactFormPresentation() + publishReadyState() + } + + private func markActionContactFormSubmitted(actionID: FlexibleID?) { + guard messageStore.markActionContactFormSubmitted(actionID: actionID) else { + return + } + + publishMessagesChanged(fileLinkResolution: .none) + } + + private func markOfflineContactFormSubmitted() { + guard messageStore.markOfflineContactFormSubmitted() else { + return + } + + publishMessagesChanged(fileLinkResolution: .none) + } + + private func clearActionContactFormSubmitError(actionID: FlexibleID?) { + updateActionContactForm(actionID: actionID) { $0.clearingSubmitError() } + } + + private func clearOfflineContactFormSubmitError() { + updateOfflineContactForm { $0.clearingSubmitError() } + } + + private func markActionContactFormSubmitError( + actionID: FlexibleID?, + message: String, + submittedFields: [String: String] + ) { + updateActionContactForm(actionID: actionID) { + $0.applyingSubmitError( + message, + submittedFields: submittedFields + ) + } + } + + private func markOfflineContactFormSubmitError( + message: String, + submittedFields: [String: String] + ) { + updateOfflineContactForm { + $0.applyingSubmitError( + message, + submittedFields: submittedFields + ) + } + } + + private func updateActionContactForm( + actionID: FlexibleID?, + transform: (ContactForm) -> ContactForm + ) { + guard messageStore.updateActionContactForm(actionID: actionID, transform: transform) else { + return + } + + publishMessagesChanged(fileLinkResolution: .none) + } + + private func updateOfflineContactForm(transform: (ContactForm) -> ContactForm) { + guard messageStore.updateOfflineContactForm(transform: transform) else { + return + } + + publishMessagesChanged(fileLinkResolution: .none) + } +} + +// MARK: - State And Events + +/// Internal controller state publication, generation validation, credential lookup, and event +/// broadcasting helpers. +package extension OnlineConsultantController { + + private func publishReadyState() { + guard let snapshot = makeSnapshot() else { return } + + setInternalState(.ready(snapshot)) + let readyState = ControllerState.ready(isAnonymous: isAnonymousSession) + if state != readyState { + setConnectionState(readyState) + } + } + + private func makeSnapshot() -> ControllerSnapshot? { + guard let connectResponse else { return nil } + + return .init( + connectResponse: connectResponse, + userToken: connectResponse.user.token, + sessionID: connectResponse.session.id?.value, + messages: messages, + consultantConfig: consultantConfig, + isOffline: isOffline, + blocksMessageInput: isOffline && connectResponse.config?.useOfflineMode == true, + unreadIncomingMessagesCount: unreadIncomingMessagesCount, + canLoadOlderMessages: canLoadOlderMessages, + contactFormPresentation: contactFormPresentation + ) + } + + private func setConnectionState(_ newState: ControllerState) { + state = newState + stateBroadcaster.yield(newState) + onStateChange?(newState) + emit(.stateChanged(newState)) + + switch newState { + case .idle: + setInternalState(.idle) + case .connecting: + setInternalState(.connecting) + case .ready: + break + case .failed(let error): + setInternalState(.failed(error.localizedDescription)) + } + } + + private func setInternalState(_ newState: ControllerInternalState) { + internalState = newState + internalStateBroadcaster.yield(newState) + } + + private func emit(_ event: ControllerEvent) { + eventBroadcaster.yield(event) + } + + private func applyFailure(_ error: Error) { + let publicError = ConsultantError.from(error) + setConnectionState(.failed(publicError)) + emit(.error(publicError.localizedDescription)) + } + + func reportError(_ error: Error) { + let publicError = ConsultantError.from(error) + emit(.error(publicError.localizedDescription)) + } + + @discardableResult + private func nextConnectionGeneration() -> UUID { + connectionGeneration = UUID() + return connectionGeneration + } + + private func ensureCurrentConnectionGeneration(_ generation: UUID) throws { + guard isCurrentConnectionGeneration(generation), !Task.isCancelled else { + throw CancellationError() + } + } + + private func isCurrentConnectionGeneration(_ generation: UUID) -> Bool { + connectionGeneration == generation + } + + private func currentCredentials() throws -> (sessionID: String, userToken: String) { + guard + let userToken = connectResponse?.user.token, + let sessionID = connectResponse?.session.id?.value + else { + throw ConsultantControllerError.notConnected + } + + return (sessionID, userToken) + } + + private func emitMessages() { + messageBroadcaster.yield(messages) + } + + private func emitUnreadIncomingMessagesCountIfNeeded(oldValue: Int) { + guard unreadIncomingMessagesCount != oldValue else { return } + + emit(.unreadIncomingMessagesCountChanged(unreadIncomingMessagesCount)) + onUnreadIncomingMessagesCountChange?(unreadIncomingMessagesCount) + } +} + +// MARK: - Input Attachment State + +/// Internal mutation helpers for input attachment status, preview data, and input attachment stream +/// events. +package extension OnlineConsultantController { + + @discardableResult + private func updateInputAttachment( + id: UUID, + status: InputAttachmentStatus + ) -> InputAttachment? { + let oldAttachment = inputAttachmentStore.attachment(id: id) + let attachment = inputAttachmentStore.updateStatus( + id: id, + status: status + ) + + guard attachment != oldAttachment else { + return attachment + } + + emitInputAttachments() + return attachment + } + + private func updateInputAttachmentPreviewData( + id: UUID, + previewData: Data + ) { + guard inputAttachmentStore.updatePreviewData(id: id, previewData: previewData) else { + return + } + + emitInputAttachments() + } + + private func emitInputAttachments() { + inputAttachmentBroadcaster.yield(inputAttachments) + } +} diff --git a/Sources/OnlineConsultant/ConsultantError.swift b/Sources/OnlineConsultant/ConsultantError.swift new file mode 100644 index 0000000..a293ed9 --- /dev/null +++ b/Sources/OnlineConsultant/ConsultantError.swift @@ -0,0 +1,154 @@ +import Foundation + +/// Errors exposed by the OnlineConsultant module. +public enum ConsultantError: Error, Equatable, LocalizedError, Sendable { + /// Failures of consultant network requests. + public enum ClientFailure: Equatable, Sendable { + /// The connection response omitted its credentials. + case missingConnectionCredentials + /// The server returned an HTTP status and optional messages. + case httpStatus(Int, [String]) + /// The request failed before a usable response was received. + case requestFailed(String) + } + + /// Failures of chat state and message operations. + public enum ControllerFailure: Equatable, Sendable { + /// A connected session is required. + case notConnected + /// Session credentials are missing. + case missingCredentials + /// The consultant is unavailable. + case unavailable + /// An upload response omitted the file identifier. + case missingUploadedFileID + /// Message attachments have not finished uploading. + case inputAttachmentsNotReady + } + + /// Failures of the local file cache. + public enum FileCacheFailure: Equatable, Sendable { + /// The file identifier is empty. + case emptyFileID + /// No cache entry exists for the file identifier. + case missingEntry(fileID: String) + /// A cache operation failed for another reason. + case operationFailed(String) + } + + /// Failures of selecting or preparing attachments. + public enum AttachmentFailure: Equatable, Sendable { + /// Image data could not be read. + case emptyImageData + /// The image exceeds the upload size limit. + case imageTooLarge + /// The file exceeds the upload size limit. + case fileTooLarge + /// The attachment count exceeds the input limit. + case tooManyAttachments + /// An attachment operation failed for another reason. + case operationFailed(String) + } + + /// A consultant network request failed. + case client(ClientFailure) + /// A chat controller operation failed. + case controller(ControllerFailure) + /// A local cache operation failed. + case fileCache(FileCacheFailure) + /// An attachment operation failed. + case attachment(AttachmentFailure) + /// An operation failed with an unclassified error. + case unexpected(String) + + /// Localized human-readable error description. + public var errorDescription: String? { + switch self { + case .client(.missingConnectionCredentials): + return ConsultantLocalization.clientErrorMissingCredentials.localized + case .client(.httpStatus(let statusCode, let messages)): + let description = ConsultantLocalization.clientErrorHTTPStatus.format(statusCode) + return messages.isEmpty ? description : description + " " + messages.joined(separator: " ") + case .client(.requestFailed(let message)): + return ConsultantLocalization.clientErrorRequestFailed.format(message) + case .controller(.notConnected): + return ConsultantLocalization.controllerErrorNotConnected.localized + case .controller(.missingCredentials): + return ConsultantLocalization.controllerErrorMissingCredentials.localized + case .controller(.unavailable): + return ConsultantLocalization.controllerErrorUnavailable.localized + case .controller(.missingUploadedFileID): + return ConsultantLocalization.controllerErrorMissingUploadedFileID.localized + case .controller(.inputAttachmentsNotReady): + return ConsultantLocalization.controllerErrorInputAttachmentsNotUploaded.localized + case .fileCache(.emptyFileID): + return ConsultantLocalization.fileCacheErrorEmptyFileID.localized + case .fileCache(.missingEntry(let fileID)): + return ConsultantLocalization.fileCacheErrorMissingEntry.format(fileID) + case .attachment(.emptyImageData): + return ConsultantLocalization.attachmentErrorEmptyImageData.localized + case .attachment(.imageTooLarge): + return ConsultantLocalization.attachmentErrorImageTooLarge.localized + case .attachment(.fileTooLarge): + return ConsultantLocalization.attachmentErrorFileTooLarge.localized + case .attachment(.tooManyAttachments): + return ConsultantLocalization.attachmentErrorTooManyAttachments.localized + case .fileCache(.operationFailed(let message)): + return ConsultantLocalization.fileCacheErrorOperationFailed.format(message) + case .attachment(.operationFailed(let message)): + return ConsultantLocalization.attachmentErrorOperationFailed.format(message) + case .unexpected(let message): + return ConsultantLocalization.unexpectedError.format(message) + } + } +} + +package extension ConsultantError { + static func cacheError(from error: Error) -> Self { + let converted = from(error) + if case .fileCache = converted { return converted } + return .fileCache(.operationFailed(error.localizedDescription)) + } + + static func from(_ error: Error) -> Self { + if let error = error as? Self { return error } + + if let error = error as? ConsultantControllerError { + switch error { + case .notConnected: return .controller(.notConnected) + case .missingCredentials: return .controller(.missingCredentials) + case .unavailable: return .controller(.unavailable) + case .missingUploadedFileID: return .controller(.missingUploadedFileID) + case .inputAttachmentsNotReady: return .controller(.inputAttachmentsNotReady) + case .inputAttachmentRemoved: return .unexpected(error.localizedDescription) + } + } + + if let error = error as? ConsultantClientError { + switch error { + case .missingConnectionCredentials: return .client(.missingConnectionCredentials) + case .httpStatus(let statusCode, let messages): + return .client(.httpStatus(statusCode, messages)) + case .requestFailed(let message): return .client(.requestFailed(message)) + } + } + + if let error = error as? ConsultantFileCacheError { + switch error { + case .emptyFileID: return .fileCache(.emptyFileID) + case .missingEntry(let fileID): return .fileCache(.missingEntry(fileID: fileID)) + } + } + + if let error = error as? AttachmentPickerError { + switch error { + case .emptyImageData: return .attachment(.emptyImageData) + case .imageTooLarge: return .attachment(.imageTooLarge) + case .fileTooLarge: return .attachment(.fileTooLarge) + case .tooManyAttachments: return .attachment(.tooManyAttachments) + } + } + + return .unexpected(error.localizedDescription) + } +} diff --git a/Sources/OnlineConsultant/CrmSdk+OnlineConsultant.swift b/Sources/OnlineConsultant/CrmSdk+OnlineConsultant.swift new file mode 100644 index 0000000..aeb9342 --- /dev/null +++ b/Sources/OnlineConsultant/CrmSdk+OnlineConsultant.swift @@ -0,0 +1,18 @@ +import Core + +public extension CrmSdk { + + /// Shared online consultant module configured during SDK initialization. + var onlineConsultant: OnlineConsultantController { + module(OnlineConsultantController.self) { + let onlineConsultant = OnlineConsultantController( + config: config.consultantConfig, + sessionRuntime: sessionRuntime + ) + Task { + await sessionRuntime.addLifecycleListener(onlineConsultant) + } + return onlineConsultant + } + } +} diff --git a/Sources/OnlineConsultant/Extensions/Array.swift b/Sources/OnlineConsultant/Extensions/Array.swift new file mode 100644 index 0000000..7c971a6 --- /dev/null +++ b/Sources/OnlineConsultant/Extensions/Array.swift @@ -0,0 +1,44 @@ +// +// Array.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +package extension Array where Element == ConsultantMessage { + + func sortedForChatDisplay() -> [ConsultantMessage] { + let indexedMessages = enumerated() + let realMessages = indexedMessages.filter { !$0.element.isVirtualSupportMessage } + let virtualSupportMessages = indexedMessages.filter { $0.element.isVirtualSupportMessage } + + return realMessages + .sorted { lhs, rhs in + let lhsDate = lhs.element.chatSortDate + let rhsDate = rhs.element.chatSortDate + + switch (lhsDate, rhsDate) { + case let (lhsDate?, rhsDate?): + if lhsDate == rhsDate { + return lhs.offset < rhs.offset + } + + return lhsDate < rhsDate + case (.some, nil): + return false + case (nil, .some): + return true + case (nil, nil): + return lhs.offset < rhs.offset + } + } + .map(\.element) + + virtualSupportMessages.map(\.element) + } + + var containsVisitorMessage: Bool { + contains { message in + message.fromMe == true && message.contactForm == nil + } + } +} diff --git a/Sources/OnlineConsultant/Extensions/ConnectedUser.swift b/Sources/OnlineConsultant/Extensions/ConnectedUser.swift new file mode 100644 index 0000000..fe3edff --- /dev/null +++ b/Sources/OnlineConsultant/Extensions/ConnectedUser.swift @@ -0,0 +1,21 @@ +// +// ConnectedUser.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 14.08.2026. +// + +import Core + +package extension ConnectedUser { + + /// Returns a copy of the connected user with submitted contact form values applied. + func applyingContactFormSubmitData(_ data: ContactFormSubmitData) -> ConnectedUser { + ConnectedUser( + token: token, + name: data.name?.nilIfEmpty ?? name, + email: data.email?.nilIfEmpty ?? email, + phone: data.phone?.nilIfEmpty ?? phone + ) + } +} diff --git a/Sources/OnlineConsultant/Extensions/String.swift b/Sources/OnlineConsultant/Extensions/String.swift new file mode 100644 index 0000000..631346e --- /dev/null +++ b/Sources/OnlineConsultant/Extensions/String.swift @@ -0,0 +1,58 @@ +// +// String.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +package extension String { + + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func trimmingPrefix(_ prefix: String) -> String { + hasPrefix(prefix) ? String(dropFirst(prefix.count)) : self + } +} + +package extension String { + + var weekdayNumber: Int? { + switch trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "sun": + return 0 + case "mon": + return 1 + case "tue": + return 2 + case "wed": + return 3 + case "thu": + return 4 + case "fri": + return 5 + case "sat": + return 6 + default: + return nil + } + } + + var timeMinutes: Int? { + let components = trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":", omittingEmptySubsequences: false) + guard + components.count >= 2, + let hour = Int(components[0]), + let minute = Int(components[1]), + (0...23).contains(hour), + (0...59).contains(minute) + else { + return nil + } + + return hour * 60 + minute + } +} diff --git a/Sources/OnlineConsultant/Extensions/UIColor.swift b/Sources/OnlineConsultant/Extensions/UIColor.swift new file mode 100644 index 0000000..beef678 --- /dev/null +++ b/Sources/OnlineConsultant/Extensions/UIColor.swift @@ -0,0 +1,43 @@ +// +// UIColor.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 22.07.2026. +// + +import UIKit + +package extension UIColor { + + convenience init(hex: UInt32, alpha: CGFloat = 1) { + self.init( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: alpha + ) + } + + convenience init?(hexString: String?) { + guard + let value = hexString? + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingPrefix("#"), + value.count == 6 || value.count == 8, + let hexValue = UInt32(value, radix: 16) + else { + return nil + } + + if value.count == 6 { + self.init(hex: hexValue) + } else { + self.init( + red: CGFloat((hexValue >> 16) & 0xFF) / 255, + green: CGFloat((hexValue >> 8) & 0xFF) / 255, + blue: CGFloat(hexValue & 0xFF) / 255, + alpha: CGFloat((hexValue >> 24) & 0xFF) / 255 + ) + } + } +} diff --git a/Sources/OnlineConsultant/Extensions/URL.swift b/Sources/OnlineConsultant/Extensions/URL.swift new file mode 100644 index 0000000..63f7020 --- /dev/null +++ b/Sources/OnlineConsultant/Extensions/URL.swift @@ -0,0 +1,34 @@ +// +// URL.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 22.07.2026. +// + +import Foundation +import UniformTypeIdentifiers + +package extension URL { + + var fileName: String { + let fileName = lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + return fileName.isEmpty ? "file-\(UUID().uuidString)" : fileName + } + + var contentType: String { + if let contentType = try? resourceValues(forKeys: [.contentTypeKey]).contentType { + return contentType.preferredMIMEType ?? "application/octet-stream" + } + + if let contentType = UTType(filenameExtension: pathExtension) { + return contentType.preferredMIMEType ?? "application/octet-stream" + } + + return "application/octet-stream" + } + + func fileSize() throws -> Int? { + let values = try resourceValues(forKeys: [.fileSizeKey]) + return values.fileSize + } +} diff --git a/Sources/OnlineConsultant/Models/Config/BackgroundTheme.swift b/Sources/OnlineConsultant/Models/Config/BackgroundTheme.swift new file mode 100644 index 0000000..02b0bdb --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/BackgroundTheme.swift @@ -0,0 +1,16 @@ +// +// BackgroundTheme.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// Known consultant background themes returned in `config.bg_color`. +package enum BackgroundTheme: String, Equatable, Sendable, CaseIterable { + + /// Light consultant background theme. + case light + + /// Dark consultant background theme. + case dark +} diff --git a/Sources/OnlineConsultant/Models/Config/ConfigForm.swift b/Sources/OnlineConsultant/Models/Config/ConfigForm.swift new file mode 100644 index 0000000..36db387 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/ConfigForm.swift @@ -0,0 +1,41 @@ +// +// ConfigForm.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// Contact form field configuration returned by the backend. +package struct ConfigForm: Decodable, Equatable, Sendable { + + /// Configured contact form fields keyed by typed backend field key. + package let fields: [ContactFormFieldKey: ContactFormField] + + private enum CodingKeys: String, CodingKey { + case name + case email + case phone + case comment + case personalData + case marketingConsent + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + fields = Dictionary(backendFields: [ + "name": try container.decodeIfPresent(ContactFormField.self, forKey: .name), + "email": try container.decodeIfPresent(ContactFormField.self, forKey: .email), + "phone": try container.decodeIfPresent(ContactFormField.self, forKey: .phone), + "comment": try container.decodeIfPresent(ContactFormField.self, forKey: .comment), + "personalData": try container.decodeIfPresent( + ContactFormField.self, + forKey: .personalData + ), + "marketingConsent": try container.decodeIfPresent( + ContactFormField.self, + forKey: .marketingConsent + ) + ].compactMapValues { $0 }) + } +} diff --git a/Sources/OnlineConsultant/Models/Config/ConsultantConfig.swift b/Sources/OnlineConsultant/Models/Config/ConsultantConfig.swift new file mode 100644 index 0000000..99dd0e0 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/ConsultantConfig.swift @@ -0,0 +1,246 @@ +import Foundation + +/// Runtime consultant configuration returned by `POST /connect`. +/// +/// The backend uses this object to describe the consultant appearance, contact form, +/// consent settings, and auxiliary domain configuration. +package struct ConsultantConfig: Decodable, Equatable, Sendable { + + /// Chat title encoded as `name`. + /// + /// The SDK applies it to `OnlineConsultantViewController.title`. + package let name: String? + + /// The consultant background theme encoded as `bg_color`. + private let bgColor: BackgroundTheme? + + /// Indicates whether main consultant text and icons should use the inverse color scheme. + package let inverse: Bool + + /// The consultant element shape encoded as `element_shape`. + private let elementShape: ElementShape? + + /// The manager identity display mode encoded as `manager_display_type`. + private let managerDisplayType: ManagerDisplayType? + + /// The custom manager display name encoded as `manager_name`. + package let managerName: String? + + /// The custom manager avatar URL encoded as `manager_logo`. + package let managerLogoURL: URL? + + /// The accent color encoded as `elements_color`. + /// + /// The backend returns the value as a hex string without a leading `#`. + package let elementsColor: String? + + /// Indicates whether the unread counter is enabled. + /// + /// `nil` means the backend did not include `use_unread_counter`; `false` explicitly disables + /// unread counting. + package let useUnreadCounter: Bool? + + /// Welcome text encoded as `intro_text`. + /// + /// The SDK renders it as a local `_intro` text message only while the chat history is empty. + package let introText: String? + + /// Indicates whether the offline mode is enabled for unavailable managers. + package let useOfflineMode: Bool + + /// Text shown by the offline mode message encoded as `offline_text`. + package let offlineText: String? + + /// Offline form field configuration encoded as `offline_form`. + package let offlineForm: ConfigForm? + + /// Success text shown after offline form submission encoded as `offline_form_result_text`. + package let offlineFormResultText: String? + + /// External consultant links encoded as `links`. + package let links: [ConsultantLink] + + /// Web widget link display mode encoded as `links_mode`. + package let linksMode: ConsultantLinksMode? + + /// Web widget links layout orientation encoded as `links_orientation`. + package let linksOrientation: ConsultantLinksOrientation? + + /// Indicates whether web desktop widgets always show external links while the chat is closed. + package let alwaysShowLinksOnDesktops: Bool + + /// Indicates whether personal data consent is required. + package let personalDataConsent: Bool + + /// The privacy policy URL encoded as `personal_data_link`. + package let personalDataLink: URL? + + /// Indicates whether the contact form is enabled for the consultant. + package let useContactForm: Bool + + /// The contact form display strategy encoded as `contact_form_display`. + package let contactFormDisplay: ContactFormDisplay? + + /// The contact form message text encoded as `contact_form_text`. + package let contactFormText: String? + + /// The contact form field configuration encoded as `contact_form`. + package let contactForm: ConfigForm? + + /// System domain configuration encoded as `domain_config`. + package let domainConfig: DomainConfig? + + private enum CodingKeys: String, CodingKey { + case name + case bgColor = "bg_color" + case inverse + case elementShape = "element_shape" + case managerDisplayType = "manager_display_type" + case managerName = "manager_name" + case managerLogo = "manager_logo" + case elementsColor = "elements_color" + case useUnreadCounter = "use_unread_counter" + case introText = "intro_text" + case useOfflineMode = "use_offline_mode" + case offlineText = "offline_text" + case offlineForm = "offline_form" + case offlineFormResultText = "offline_form_result_text" + case links + case linksMode = "links_mode" + case linksOrientation = "links_orientation" + case alwaysShowLinksOnDesktops = "always_show_links_on_desktops" + case personalDataConsent = "personal_data_consent" + case personalDataLink = "personal_data_link" + case useContactForm = "use_contact_form" + case useContactFormCamel = "useContactForm" + case contactFormDisplay = "contact_form_display" + case contactFormDisplayCamel = "contactFormDisplay" + case contactFormText = "contact_form_text" + case contactFormTextCamel = "contactFormText" + case contactForm = "contact_form" + case contactFormCamel = "contactForm" + case domainConfig = "domain_config" + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try Self.decodeTrimmedString(from: container, forKey: .name) + bgColor = try Self.decodeEnum(from: container, forKey: .bgColor) + inverse = try Self.decodeBool(from: container, forKey: .inverse) + elementShape = try Self.decodeEnum(from: container, forKey: .elementShape) + managerDisplayType = try Self.decodeEnum(from: container, forKey: .managerDisplayType) + managerName = try Self.decodeTrimmedString(from: container, forKey: .managerName) + managerLogoURL = try Self.decodeURL(from: container, forKey: .managerLogo) + elementsColor = try Self.decodeTrimmedString(from: container, forKey: .elementsColor) + useUnreadCounter = try Self.decodeBoolIfPresent(from: container, forKey: .useUnreadCounter) + introText = try Self.decodeTrimmedString(from: container, forKey: .introText) + useOfflineMode = try Self.decodeBool(from: container, forKey: .useOfflineMode) + offlineText = try Self.decodeTrimmedString(from: container, forKey: .offlineText) + offlineForm = try container.decodeIfPresent(ConfigForm.self, forKey: .offlineForm) + offlineFormResultText = try Self.decodeTrimmedString( + from: container, + forKey: .offlineFormResultText + ) + links = try container.decodeIfPresent([ConsultantLink].self, forKey: .links) ?? [] + linksMode = try Self.decodeTrimmedString(from: container, forKey: .linksMode) + .map(ConsultantLinksMode.init(rawValue:)) + linksOrientation = try Self.decodeTrimmedString(from: container, forKey: .linksOrientation) + .map(ConsultantLinksOrientation.init(rawValue:)) + alwaysShowLinksOnDesktops = try Self.decodeBool(from: container, forKey: .alwaysShowLinksOnDesktops) + personalDataConsent = try Self.decodeBool(from: container, forKey: .personalDataConsent) + personalDataLink = try Self.decodeURL(from: container, forKey: .personalDataLink) + useContactForm = try Self.decodeBool(from: container, forKey: .useContactForm) + || Self.decodeBoolIfPresent(from: container, forKey: .useContactFormCamel) == true + contactFormDisplay = try Self.decodeEnum(from: container, forKey: .contactFormDisplay) + ?? Self.decodeEnum(from: container, forKey: .contactFormDisplayCamel) + contactFormText = try Self.decodeTrimmedString(from: container, forKey: .contactFormText) + ?? Self.decodeTrimmedString(from: container, forKey: .contactFormTextCamel) + contactForm = try container.decodeIfPresent(ConfigForm.self, forKey: .contactForm) + ?? container.decodeIfPresent(ConfigForm.self, forKey: .contactFormCamel) + domainConfig = try container.decodeIfPresent(DomainConfig.self, forKey: .domainConfig) + } +} + +package extension ConsultantConfig { + + /// External link settings exposed to host applications. + var linkSettings: LinkSettings { + LinkSettings( + links: links, + linksMode: linksMode, + linksOrientation: linksOrientation, + alwaysShowLinksOnDesktops: alwaysShowLinksOnDesktops + ) + } + + /// Indicates whether the consultant uses the dark background theme. + var isDarkBackground: Bool { + bgColor == .dark + } + + /// Indicates whether consultant elements should use rounded shapes. + var roundedElements: Bool { + elementShape != .rectangular + } + + /// Indicates whether the consultant should use custom manager identity values. + var usesCustomManagerDisplay: Bool { + managerDisplayType == .custom + } + + /// Non-empty image worker hosts from `domainConfig`. + var imageWorkers: [String] { + domainConfig?.imageWorkers.compactMap { worker in + let trimmedWorker = worker.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedWorker.isEmpty ? nil : trimmedWorker + } ?? [] + } +} + +package extension ConsultantConfig { + + private static func decodeBool( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> Bool { + try decodeBoolIfPresent(from: container, forKey: key) ?? false + } + + private static func decodeBoolIfPresent( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> Bool? { + try container.decodeIfPresent(Bool.self, forKey: key) + } + + private static func decodeTrimmedString( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> String? { + let value = try container.decodeIfPresent(String.self, forKey: key)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return value?.isEmpty == true ? nil : value + } + + private static func decodeEnum( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> Value? where Value.RawValue == String { + guard let value = try decodeTrimmedString(from: container, forKey: key) else { + return nil + } + + return Value(rawValue: value) + } + + static func decodeURL( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> URL? { + guard let value = try decodeTrimmedString(from: container, forKey: key) else { + return nil + } + + return URL(string: value) + } +} diff --git a/Sources/OnlineConsultant/Models/Config/ContactFormDisplay.swift b/Sources/OnlineConsultant/Models/Config/ContactFormDisplay.swift new file mode 100644 index 0000000..e134bb1 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/ContactFormDisplay.swift @@ -0,0 +1,16 @@ +// +// ContactFormDisplay.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// Known contact form display strategies returned in `config.contact_form_display`. +package enum ContactFormDisplay: String, Codable, Equatable, Sendable { + + /// Show the contact form before the visitor sends the first message. + case atBeginningDialogue = "at_beginning_dialogue" + + /// Show the contact form after the visitor sends the first message. + case afterFirstMessage = "after_first_message" +} diff --git a/Sources/OnlineConsultant/Models/Config/DomainConfig.swift b/Sources/OnlineConsultant/Models/Config/DomainConfig.swift new file mode 100644 index 0000000..46b02c5 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/DomainConfig.swift @@ -0,0 +1,59 @@ +import Foundation + +// +// DomainConfig.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// System domain configuration returned in `config.domain_config`. +package struct DomainConfig: Decodable, Equatable, Sendable { + + /// Branding URL opened when the footer logo is tapped. + package let url: URL? + + /// Branding logo URL displayed in the footer. + package let logo: URL? + + /// Indicates whether the connected CRM is on a free plan. + private let free: Bool + + /// Indicates whether the SDK should show the branding footer. + package let footer: Bool + + /// Image worker hosts used to transform image URLs. + package let imageWorkers: [String] + + private enum CodingKeys: String, CodingKey { + case url = "URL" + case logo + case free + case footer + case imageWorkers = "image_workers" + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + url = try Self.decodeURL(from: container, forKey: .url) + logo = try Self.decodeURL(from: container, forKey: .logo) + free = try container.decodeIfPresent(Bool.self, forKey: .free) ?? false + footer = try container.decodeIfPresent(Bool.self, forKey: .footer) ?? false + imageWorkers = try container.decodeIfPresent([String].self, forKey: .imageWorkers) ?? [] + } + + private static func decodeURL( + from container: KeyedDecodingContainer, + forKey key: Key + ) throws -> URL? { + guard + let value = try container.decodeIfPresent(String.self, forKey: key)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + + return URL(string: value) + } +} diff --git a/Sources/OnlineConsultant/Models/Config/ElementShape.swift b/Sources/OnlineConsultant/Models/Config/ElementShape.swift new file mode 100644 index 0000000..0db106e --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/ElementShape.swift @@ -0,0 +1,16 @@ +// +// ElementShape.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// Known consultant element shapes returned in `config.element_shape`. +package enum ElementShape: String, Equatable, Sendable, CaseIterable { + + /// Rounded widconsultantget elements. + case rounded + + /// Rectangular consultant elements. + case rectangular = "rectangular form" +} diff --git a/Sources/OnlineConsultant/Models/Config/Links/ConsultantLink.swift b/Sources/OnlineConsultant/Models/Config/Links/ConsultantLink.swift new file mode 100644 index 0000000..e47bd83 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/Links/ConsultantLink.swift @@ -0,0 +1,228 @@ +import Foundation + +/// External consultant link returned in `config.links`. +/// +/// The backend uses these links for social networks and messenger channels. The SDK exposes the +/// decoded data to host applications and does not render these links in its built-in UI. +public struct ConsultantLink: Codable, Equatable, Sendable { + + /// Link type encoded as `type`, for example `telegram`, `whatsapp`, or `vk`. + public let type: ConsultantLinkType? + + /// Backend link value encoded as `url`. + /// + /// Depending on `type`, this can be a full URL, username, channel id, or phone number. + public let url: String? + + /// Optional backend prefix encoded as `prefix`. + /// + /// Telegram links can use prefixes such as `t.me/` or `teleg.run/`. + public let prefix: String? + + /// Creates an external consultant link value. + /// + /// - Parameters: + /// - type: Type of external consultant link. + /// - url: Backend link value, which may be a URL, username, channel ID, or phone number. + /// - prefix: Optional prefix used to form the consultant link URL. + public init( + type: ConsultantLinkType? = nil, + url: String? = nil, + prefix: String? = nil + ) { + self.type = type + self.url = url + self.prefix = prefix + } + + /// Best-effort URL built with the same known type prefixes as the web widget. + public var resolvedURL: URL? { + guard let value = url?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if let directURL = URL(string: value), directURL.scheme != nil { + return directURL + } + + if let prefix = prefix?.trimmingCharacters(in: .whitespacesAndNewlines), !prefix.isEmpty { + return URL(string: "https://\(prefix)\(value)") + } + + guard let type else { + return URL(string: value) + } + + return URL(string: type.commonURLPrefix + value) + } +} + +extension ConsultantLink { + private struct CodingValue: Codable { + let type: ConsultantLinkType? + let url: String? + let prefix: String? + } + + /// Decodes a `ConsultantLink` from its serialized representation. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + do { + let value = try CodingValue(from: decoder) + self.init(type: value.type, url: value.url, prefix: value.prefix) + } catch { + throw ConsultantError.from(error) + } + } + + /// Encodes this `ConsultantLink` for serialization. + /// + /// - Parameters: + /// - encoder: Encoder receiving this value. + public func encode(to encoder: Encoder) throws { + do { + try CodingValue(type: type, url: url, prefix: prefix).encode(to: encoder) + } catch { + throw ConsultantError.from(error) + } + } +} + +/// Known external consultant link types returned in `config.links[].type`. +public enum ConsultantLinkType: Codable, Equatable, Sendable, ExpressibleByStringLiteral { + + /// Facebook profile/page link. + case fb + + /// Facebook Messenger link. + case fbmessenger + + /// WhatsApp link. + case whatsapp + + /// Telegram link. + case telegram + + /// VK link. + case vk + + /// Viber public account link. + case viber + + /// Instagram link. + case instagram + + /// MAX messenger link. + case max + + /// Link type that is not known by this SDK version. + case unknown(String) + + /// String value used in backend JSON payloads. + public var rawValue: String { + switch self { + case .fb: + return "fb" + case .fbmessenger: + return "fbmessenger" + case .whatsapp: + return "whatsapp" + case .telegram: + return "telegram" + case .vk: + return "vk" + case .viber: + return "viber" + case .instagram: + return "instagram" + case .max: + return "max" + case .unknown(let value): + return value + } + } + + package var commonURLPrefix: String { + switch self { + case .fb: + return "https://fb.me/" + case .fbmessenger: + return "https://m.me/" + case .whatsapp: + return "https://wa.me/" + case .telegram: + return "https://t.me/" + case .vk: + return "https://vk.me/" + case .viber: + return "viber://pa?chatURI=" + case .instagram: + return "https://instagram.com/" + case .max, .unknown: + return "" + } + } + + /// Creates a link type from a backend string value. + /// + /// - Parameters: + /// - rawValue: Value received from the consultant backend. + public init(rawValue: String) { + switch rawValue { + case "fb": + self = .fb + case "fbmessenger": + self = .fbmessenger + case "whatsapp": + self = .whatsapp + case "telegram": + self = .telegram + case "vk": + self = .vk + case "viber": + self = .viber + case "instagram": + self = .instagram + case "max": + self = .max + default: + self = .unknown(rawValue) + } + } + + /// Creates a consultant link type from a string literal. + /// + /// - Parameters: + /// - value: String literal representing a backend value. + public init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + + /// Decodes a `ConsultantLinkType` value from its serialized representation. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } catch { + throw ConsultantError.from(error) + } + } + + /// Encodes this `ConsultantLinkType` value for serialization. + /// + /// - Parameters: + /// - encoder: Encoder receiving this value. + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } catch { + throw ConsultantError.from(error) + } + } +} diff --git a/Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksMode.swift b/Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksMode.swift new file mode 100644 index 0000000..ee5b16c --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksMode.swift @@ -0,0 +1,87 @@ +/// Known display modes returned in `config.links_mode`. +public enum ConsultantLinksMode: Codable, Equatable, Sendable, ExpressibleByStringLiteral { + + /// Standard consultant link style. + case standard + + /// Black consultant link style. + case black + + /// Grey consultant link style. + case grey + + /// Widget-colored link style. + case widget + + /// Links mode that is not known by this SDK version. + case unknown(String) + + /// String value used in backend JSON payloads. + public var rawValue: String { + switch self { + case .standard: + return "standard" + case .black: + return "black" + case .grey: + return "grey" + case .widget: + return "widget" + case .unknown(let value): + return value + } + } + + /// Creates a links mode from a backend string value. + /// + /// - Parameters: + /// - rawValue: Value received from the consultant backend. + public init(rawValue: String) { + switch rawValue { + case "standard": + self = .standard + case "black": + self = .black + case "grey": + self = .grey + case "widget": + self = .widget + default: + self = .unknown(rawValue) + } + } + + /// Creates a consultant links display mode from a string literal. + /// + /// - Parameters: + /// - value: String literal representing a backend value. + public init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + + /// Decodes a `ConsultantLinksMode` value from its serialized representation. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } catch { + throw ConsultantError.from(error) + } + } + + /// Encodes this `ConsultantLinksMode` value for serialization. + /// + /// - Parameters: + /// - encoder: Encoder receiving this value. + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } catch { + throw ConsultantError.from(error) + } + } +} diff --git a/Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksOrientation.swift b/Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksOrientation.swift new file mode 100644 index 0000000..c23763f --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/Links/ConsultantLinksOrientation.swift @@ -0,0 +1,73 @@ +/// Known layout orientations returned in `config.links_orientation`. +public enum ConsultantLinksOrientation: Codable, Equatable, Sendable, ExpressibleByStringLiteral { + + /// Vertical web widget links layout. + case vertical + + /// Horizontal web widget links layout. + case horizontal + + /// Links orientation that is not known by this SDK version. + case unknown(String) + + /// String value used in backend JSON payloads. + public var rawValue: String { + switch self { + case .vertical: + return "vertical" + case .horizontal: + return "horizontal" + case .unknown(let value): + return value + } + } + + /// Creates a links orientation from a backend string value. + /// + /// - Parameters: + /// - rawValue: Value received from the consultant backend. + public init(rawValue: String) { + switch rawValue { + case "vertical": + self = .vertical + case "horizontal": + self = .horizontal + default: + self = .unknown(rawValue) + } + } + + /// Creates a consultant links orientation from a string literal. + /// + /// - Parameters: + /// - value: String literal representing a backend value. + public init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + + /// Decodes a `ConsultantLinksOrientation` value from its serialized representation. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } catch { + throw ConsultantError.from(error) + } + } + + /// Encodes this `ConsultantLinksOrientation` value for serialization. + /// + /// - Parameters: + /// - encoder: Encoder receiving this value. + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } catch { + throw ConsultantError.from(error) + } + } +} diff --git a/Sources/OnlineConsultant/Models/Config/Links/LinkSettings.swift b/Sources/OnlineConsultant/Models/Config/Links/LinkSettings.swift new file mode 100644 index 0000000..ec24f10 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/Links/LinkSettings.swift @@ -0,0 +1,28 @@ +/// External consultant link configuration received from the backend. +public struct LinkSettings: Equatable, Sendable { + + /// External consultant links from `config.links`. + public let links: [ConsultantLink] + + /// Link display mode from `config.links_mode`. + public let linksMode: ConsultantLinksMode? + + /// Link layout orientation from `config.links_orientation`. + public let linksOrientation: ConsultantLinksOrientation? + + /// Whether web desktop widgets always show links while the chat is closed. + public let alwaysShowLinksOnDesktops: Bool + + /// Creates external consultant link settings. + public init( + links: [ConsultantLink] = [], + linksMode: ConsultantLinksMode? = nil, + linksOrientation: ConsultantLinksOrientation? = nil, + alwaysShowLinksOnDesktops: Bool = false + ) { + self.links = links + self.linksMode = linksMode + self.linksOrientation = linksOrientation + self.alwaysShowLinksOnDesktops = alwaysShowLinksOnDesktops + } +} diff --git a/Sources/OnlineConsultant/Models/Config/ManagerDisplayType.swift b/Sources/OnlineConsultant/Models/Config/ManagerDisplayType.swift new file mode 100644 index 0000000..947effe --- /dev/null +++ b/Sources/OnlineConsultant/Models/Config/ManagerDisplayType.swift @@ -0,0 +1,16 @@ +// +// ManagerDisplayType.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// Known manager identity display modes returned in `config.manager_display_type`. +package enum ManagerDisplayType: String, Equatable, Sendable, CaseIterable { + + /// Use manager identity from the message/backend data. + case standard + + /// Use custom manager identity from `manager_name` and `manager_logo`. + case custom +} diff --git a/Sources/OnlineConsultant/Models/Configuration/ConsultantCacheStatistics.swift b/Sources/OnlineConsultant/Models/Configuration/ConsultantCacheStatistics.swift new file mode 100644 index 0000000..57c4800 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Configuration/ConsultantCacheStatistics.swift @@ -0,0 +1,32 @@ +// +// ConsultantCacheStatistics.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Local SDK file cache statistics. +/// +/// This model is not decoded from the consultant backend. It describes the current on-device cache +/// state returned by `cacheStatistics()` and `trimCache()`. +public struct ConsultantCacheStatistics: Equatable, Sendable { + + /// Number of cached file entries currently tracked by the cache manifest. + public let fileCount: Int + + /// Total disk size of cached files in bytes. + public let totalDiskSizeBytes: Int64 + + /// Creates a file cache statistics snapshot. + /// + /// - Parameters: + /// - fileCount: Number of files currently present in the local chat cache. + /// - totalDiskSizeBytes: Total size of local cached files in bytes. + public init( + fileCount: Int, + totalDiskSizeBytes: Int64 + ) { + self.fileCount = fileCount + self.totalDiskSizeBytes = totalDiskSizeBytes + } +} diff --git a/Sources/OnlineConsultant/Models/ContactForm/ContactForm.swift b/Sources/OnlineConsultant/Models/ContactForm/ContactForm.swift new file mode 100644 index 0000000..39f53f2 --- /dev/null +++ b/Sources/OnlineConsultant/Models/ContactForm/ContactForm.swift @@ -0,0 +1,170 @@ +import Foundation + +/// SDK presentation state for a contact form shown in the chat UI. +/// +/// The backend returns contact form field configuration in `config.contact_form` and +/// `actions[].contact_form`. The SDK combines that backend configuration with local UI state, +/// prefilled user data, action identifiers, and submit status so the form can be rendered and +/// retried without losing user input. +package struct ContactForm: Codable, Equatable, Sendable { + + /// Message text shown above the form fields. + package let text: String? + + /// Message text shown after the form is successfully submitted. + package let resultText: String? + + /// Placement strategy used by the SDK when showing the form. + package let display: ContactFormDisplay + + /// Local form scenario used to route submit handling. + package let kind: ContactFormKind + + /// Backend field configuration keyed by typed backend field key. + package let fields: [ContactFormFieldKey: ContactFormField] + + /// User values used to prefill supported contact fields. + package let prefill: ContactFormPrefill + + /// Last submitted values retained after a failed submit attempt. + package let fieldValues: [String: String] + + /// Site action identifier used when the form was generated by an action. + package let actionID: FlexibleID? + + /// Indicates that the form was successfully submitted and should be locked. + package let isSubmitted: Bool + + /// Inline submit error message shown after a failed submit attempt. + package let submitErrorMessage: String? + + private enum CodingKeys: String, CodingKey { + case text + case resultText + case display + case kind + case fields + case prefill + case fieldValues + case actionID + case isSubmitted + case submitErrorMessage + } + + /// Creates contact form presentation state from backend configuration and local SDK state. + package init( + text: String? = nil, + resultText: String? = nil, + display: ContactFormDisplay = .afterFirstMessage, + kind: ContactFormKind = .contact, + fields: [ContactFormFieldKey: ContactFormField], + prefill: ContactFormPrefill = ContactFormPrefill(), + fieldValues: [String: String] = [:], + actionID: FlexibleID? = nil, + isSubmitted: Bool = false, + submitErrorMessage: String? = nil + ) { + self.text = text + self.resultText = resultText + self.display = display + self.kind = kind + self.fields = fields + self.prefill = prefill + self.fieldValues = fieldValues + self.actionID = actionID + self.isSubmitted = isSubmitted + self.submitErrorMessage = submitErrorMessage + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + text = try container.decodeIfPresent(String.self, forKey: .text) + resultText = try container.decodeIfPresent(String.self, forKey: .resultText) + display = try container.decodeIfPresent( + ContactFormDisplay.self, + forKey: .display + ) ?? .afterFirstMessage + kind = try container.decodeIfPresent(ContactFormKind.self, forKey: .kind) ?? .contact + fields = try Dictionary( + backendFields: container.decode([String: ContactFormField].self, forKey: .fields) + ) + prefill = try container.decodeIfPresent( + ContactFormPrefill.self, + forKey: .prefill + ) ?? ContactFormPrefill() + fieldValues = try container.decodeIfPresent([String: String].self, forKey: .fieldValues) ?? [:] + actionID = try container.decodeIfPresent(FlexibleID.self, forKey: .actionID) + isSubmitted = try container.decodeIfPresent(Bool.self, forKey: .isSubmitted) ?? false + submitErrorMessage = try container.decodeIfPresent(String.self, forKey: .submitErrorMessage) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(resultText, forKey: .resultText) + try container.encode(display, forKey: .display) + try container.encode(kind, forKey: .kind) + try container.encode(fields.backendFields, forKey: .fields) + try container.encode(prefill, forKey: .prefill) + try container.encode(fieldValues, forKey: .fieldValues) + try container.encodeIfPresent(actionID, forKey: .actionID) + try container.encode(isSubmitted, forKey: .isSubmitted) + try container.encodeIfPresent(submitErrorMessage, forKey: .submitErrorMessage) + } +} + +package extension ContactForm { + + /// Returns a copy marked as successfully submitted. + func submitted() -> ContactForm { + .init( + text: text, + resultText: resultText, + display: display, + kind: kind, + fields: fields, + prefill: prefill, + fieldValues: fieldValues, + actionID: actionID, + isSubmitted: true, + submitErrorMessage: nil + ) + } + + /// Returns a copy with the inline submit error cleared. + func clearingSubmitError() -> ContactForm { + .init( + text: text, + resultText: resultText, + display: display, + kind: kind, + fields: fields, + prefill: prefill, + fieldValues: fieldValues, + actionID: actionID, + isSubmitted: isSubmitted, + submitErrorMessage: nil + ) + } + + /// Returns a copy that preserves submitted values and shows an inline submit error. + func applyingSubmitError( + _ message: String, + submittedFields: [String: String] + ) -> ContactForm { + .init( + text: text, + resultText: resultText, + display: display, + kind: kind, + fields: fields, + prefill: prefill, + fieldValues: submittedFields, + actionID: actionID, + isSubmitted: false, + submitErrorMessage: message + ) + } +} diff --git a/Sources/OnlineConsultant/Models/ContactForm/ContactFormField.swift b/Sources/OnlineConsultant/Models/ContactForm/ContactFormField.swift new file mode 100644 index 0000000..4fc349b --- /dev/null +++ b/Sources/OnlineConsultant/Models/ContactForm/ContactFormField.swift @@ -0,0 +1,18 @@ +// +// ContactFormField.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Backend contact form field configuration. +package struct ContactFormField: Codable, Equatable, Sendable { + + /// Indicates that the visitor must fill or accept this field before submit. + package let required: Bool + + /// Creates a contact form field configuration. + package init(required: Bool = false) { + self.required = required + } +} diff --git a/Sources/OnlineConsultant/Models/ContactForm/ContactFormFieldKey.swift b/Sources/OnlineConsultant/Models/ContactForm/ContactFormFieldKey.swift new file mode 100644 index 0000000..e103abb --- /dev/null +++ b/Sources/OnlineConsultant/Models/ContactForm/ContactFormFieldKey.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Backend key of a contact form field. +/// +/// The backend has known field keys such as `name`, `phone`, `email`, `personalData`, +/// and `marketingConsent`, but site actions may also contain custom field keys. Unknown keys are +/// preserved as `.custom` so the SDK can render them instead of dropping backend configuration. +package enum ContactFormFieldKey: Hashable, Codable, Sendable, ExpressibleByStringLiteral { + + /// Customer name field. + case name + + /// Customer phone field. + case phone + + /// Customer email field. + case email + + /// Required personal-data consent field. + case personalData + + /// Optional or required marketing consent field. + case marketingConsent + + /// Custom backend field key not known by the SDK. + case custom(String) + + /// Backend string value for this field key. + package var rawValue: String { + switch self { + case .name: + return "name" + case .phone: + return "phone" + case .email: + return "email" + case .personalData: + return "personalData" + case .marketingConsent: + return "marketingConsent" + case .custom(let value): + return value + } + } + + /// Creates a field key from a backend string value. + package init(_ rawValue: String) { + switch rawValue { + case "name": + self = .name + case "phone": + self = .phone + case "email": + self = .email + case "personalData": + self = .personalData + case "marketingConsent": + self = .marketingConsent + default: + self = .custom(rawValue) + } + } + + package init(stringLiteral value: String) { + self.init(value) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(try container.decode(String.self)) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +extension ContactFormFieldKey: Comparable { + + package static func < ( + lhs: ContactFormFieldKey, + rhs: ContactFormFieldKey + ) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +package extension Dictionary where Key == ContactFormFieldKey { + + /// Accesses field dictionaries by a backend string key. + subscript(_ rawKey: String) -> Value? { + get { + self[ContactFormFieldKey(rawKey)] + } + set { + self[ContactFormFieldKey(rawKey)] = newValue + } + } +} + +package extension Dictionary where Key == ContactFormFieldKey { + + init(backendFields: [String: Value]) { + self.init( + uniqueKeysWithValues: backendFields.map { + (ContactFormFieldKey($0.key), $0.value) + } + ) + } + + var backendFields: [String: Value] { + Swift.Dictionary( + uniqueKeysWithValues: map { + ($0.key.rawValue, $0.value) + } + ) + } +} diff --git a/Sources/OnlineConsultant/Models/ContactForm/ContactFormKind.swift b/Sources/OnlineConsultant/Models/ContactForm/ContactFormKind.swift new file mode 100644 index 0000000..3f879da --- /dev/null +++ b/Sources/OnlineConsultant/Models/ContactForm/ContactFormKind.swift @@ -0,0 +1,9 @@ +/// Local SDK contact form scenario. +package enum ContactFormKind: String, Codable, Equatable, Sendable { + + /// Regular contact form submitted to `/contact/form`. + case contact + + /// Offline contact form submitted to `/offline/form`. + case offline +} diff --git a/Sources/OnlineConsultant/Models/ContactForm/ContactFormPrefill.swift b/Sources/OnlineConsultant/Models/ContactForm/ContactFormPrefill.swift new file mode 100644 index 0000000..561c0a0 --- /dev/null +++ b/Sources/OnlineConsultant/Models/ContactForm/ContactFormPrefill.swift @@ -0,0 +1,30 @@ +// +// ContactFormPrefill.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// User contact values used to prefill a contact form. +package struct ContactFormPrefill: Codable, Equatable, Sendable { + + /// Prefilled visitor name. + package let name: String? + + /// Prefilled visitor email. + package let email: String? + + /// Prefilled visitor phone. + package let phone: String? + + /// Creates prefilled contact values. + package init( + name: String? = nil, + email: String? = nil, + phone: String? = nil + ) { + self.name = name + self.email = email + self.phone = phone + } +} diff --git a/Sources/OnlineConsultant/Models/ContactForm/ContactFormSubmitData.swift b/Sources/OnlineConsultant/Models/ContactForm/ContactFormSubmitData.swift new file mode 100644 index 0000000..4886b8e --- /dev/null +++ b/Sources/OnlineConsultant/Models/ContactForm/ContactFormSubmitData.swift @@ -0,0 +1,59 @@ +/// Contact form values submitted by the SDK user. +/// +/// The backend request body supports `name`, `email`, `phone`, `comment`, and `marketingConsent`. +/// `actionID` is sent separately as the `action_id` query parameter when the form belongs to a site +/// action. Personal data consent is validated locally by the UI and is not sent to the backend. +package struct ContactFormSubmitData: Equatable, Sendable { + + /// Customer name sent as `name`. + package let name: String? + + /// Customer email sent as `email`. + package let email: String? + + /// Customer phone sent as `phone`. + package let phone: String? + + /// Customer comment sent as `comment`. + package let comment: String? + + /// Optional marketing consent flag sent as `marketingConsent`. + package let marketingConsent: Bool? + + /// Site action identifier sent as the `action_id` query parameter. + package let actionID: FlexibleID? + + /// Local form scenario used by the SDK to choose the submit endpoint. + package let kind: ContactFormKind + + /// Creates contact form submit data matching the backend contact-form contract. + package init( + name: String? = nil, + email: String? = nil, + phone: String? = nil, + comment: String? = nil, + marketingConsent: Bool? = nil, + actionID: FlexibleID? = nil, + kind: ContactFormKind = .contact + ) { + self.name = name?.nilIfEmpty + self.email = email?.nilIfEmpty + self.phone = phone?.nilIfEmpty + self.comment = comment?.nilIfEmpty + self.marketingConsent = marketingConsent + self.actionID = actionID + self.kind = kind + } +} + +package extension ContactFormSubmitData { + + var submittedFields: [String: String] { + [ + "name": name, + "email": email, + "phone": phone, + "comment": comment + ].compactMapValues { $0?.nilIfEmpty } + } +} diff --git a/Sources/OnlineConsultant/Models/ContactForm/ContactFormUser.swift b/Sources/OnlineConsultant/Models/ContactForm/ContactFormUser.swift new file mode 100644 index 0000000..8419289 --- /dev/null +++ b/Sources/OnlineConsultant/Models/ContactForm/ContactFormUser.swift @@ -0,0 +1,76 @@ +// +// ContactFormUser.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Core + +/// Local contact-form user state used to prefill supported contact fields and decide whether a +/// contact form is still required. +/// +/// The backend persists submitted `name`, `email`, and `phone` values on the consultant user. +/// Submit-only values such as `comment`, `marketingConsent`, and personal-data consent are not part +/// of this model. +package struct ContactFormUser: Equatable, Sendable { + + /// User name used for the `name` field prefill and required-field checks. + package let name: String? + + /// Email address used for the `email` field prefill and required-field checks. + package let email: String? + + /// Phone number used for the `phone` field prefill and required-field checks. + package let phone: String? + + /// Creates contact-form user state from known contact values. + package init( + name: String? = nil, + email: String? = nil, + phone: String? = nil + ) { + self.name = name?.nilIfEmpty + self.email = email?.nilIfEmpty + self.phone = phone?.nilIfEmpty + } + + /// Builds contact-form user state from the connected backend user and SDK-provided fallback + /// user data. + package init( + connectedUser: ConnectedUser, + fallbackUser: ConnectUser + ) { + self.init( + name: connectedUser.name?.nilIfEmpty ?? fallbackUser.name?.nilIfEmpty, + email: connectedUser.email?.nilIfEmpty ?? fallbackUser.email?.nilIfEmpty, + phone: connectedUser.phone?.nilIfEmpty ?? fallbackUser.phone?.nilIfEmpty + ) + } + + /// Returns a copy with backend-persisted contact fields from submitted form data applied. + package func merging(_ data: ContactFormSubmitData) -> ContactFormUser { + .init( + name: data.name?.nilIfEmpty ?? name, + email: data.email?.nilIfEmpty ?? email, + phone: data.phone?.nilIfEmpty ?? phone + ) + } +} + +package extension ContactFormUser { + + /// Returns whether a supported contact field already has a non-empty value. + func hasFilledValue(for field: ContactFormFieldKey) -> Bool { + switch field { + case .name: + return name?.isEmpty == false + case .email: + return email?.isEmpty == false + case .phone: + return phone?.isEmpty == false + default: + return false + } + } +} diff --git a/Sources/OnlineConsultant/Models/Core/ConsultantJSONValue.swift b/Sources/OnlineConsultant/Models/Core/ConsultantJSONValue.swift new file mode 100644 index 0000000..afb8191 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Core/ConsultantJSONValue.swift @@ -0,0 +1,56 @@ +import Foundation + +package enum ConsultantJSONValue: Codable, Equatable, Sendable { + + case string(String) + case number(Double) + case bool(Bool) + case object([String: ConsultantJSONValue]) + case array([ConsultantJSONValue]) + case null + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode([String: ConsultantJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([ConsultantJSONValue].self) { + self = .array(value) + } else { + throw DecodingError.typeMismatch( + ConsultantJSONValue.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported JSON value" + ) + ) + } + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} diff --git a/Sources/OnlineConsultant/Models/Core/FlexibleID.swift b/Sources/OnlineConsultant/Models/Core/FlexibleID.swift new file mode 100644 index 0000000..1aeca51 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Core/FlexibleID.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Identifier wrapper that accepts backend IDs encoded as either strings or numbers. +/// +/// The SDK normalizes every decoded identifier to a string so UI, caching, and comparisons can use +/// one stable representation even when backend payloads are not type-consistent. +package struct FlexibleID: Codable, Equatable, Hashable, Sendable { + + /// Normalized string value of the identifier. + package let value: String + + /// Creates an identifier from an already normalized string value. + package init(_ value: String) { + self.value = value + } + + /// Decodes a string or numeric JSON value and stores it as a string. + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let value = try? container.decode(String.self) { + self.value = value + return + } + + if let value = try? container.decode(Int.self) { + self.value = String(value) + return + } + + if let value = try? container.decode(Int64.self) { + self.value = String(value) + return + } + + if let value = try? container.decode(Double.self) { + self.value = String(value) + return + } + + throw DecodingError.typeMismatch( + FlexibleID.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected string or numeric id" + ) + ) + } + + /// Encodes the identifier as a string value. + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(value) + } +} diff --git a/Sources/OnlineConsultant/Models/Message/ConsultantMessage.swift b/Sources/OnlineConsultant/Models/Message/ConsultantMessage.swift new file mode 100644 index 0000000..1cab88a --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/ConsultantMessage.swift @@ -0,0 +1,658 @@ +import Foundation + +/// Chat message returned by the consultant backend or created locally by the SDK for synthetic UI messages. +package struct ConsultantMessage: Codable, Equatable, Sendable { + + /// Backend message identifier from the `id` field. + package let id: FlexibleID? + + /// Message text from the backend `content` field. + package let content: String? + + /// Local SDK title used for contact-form messages. + /// + /// The backend `Message` response does not include this field. + package let header: String? + + /// Product payload from the backend `product` field. + package let product: MessageProduct? + + /// Order payload from the backend `order` field. + package let order: MessageOrder? + + /// File or image items from the backend `items` field. + package let items: [MessageItem]? + + /// Text of the quoted message from the backend `quote_content` field. + package let quoteContent: String? + + /// Identifier of the quoted message from backend `quote_id`. + package let quoteID: FlexibleID? + + /// Preview resolved from the currently loaded history; never encoded. + package var resolvedQuotePreview: MessageQuotePreview? = nil + + /// Message creation time from the backend `time` field. + package let time: String? + + /// Message update time from the backend `updated_at` field. + package let updatedAt: String? + + /// Sender data from the backend `user` field. + package let user: MessageUser? + + /// Direction flag from the backend `from_me` field. + package let fromMe: Bool? + + /// Message type from the backend `type` field or a local SDK synthetic message type. + package let messageType: MessageType? + + /// Delivery or read status from the backend `status` field or a local SDK synthetic message status. + package let status: MessageStatus? + + /// Local SDK contact form payload used to render synthetic contact-form messages. + /// + /// The backend `Message` response does not include this field. + package let contactForm: ContactForm? + + /// Creates a message instance for decoded backend messages, local outgoing messages, or SDK-generated UI messages. + package init( + id: FlexibleID? = nil, + content: String? = nil, + header: String? = nil, + product: MessageProduct? = nil, + order: MessageOrder? = nil, + items: [MessageItem]? = nil, + quoteContent: String? = nil, + quoteID: FlexibleID? = nil, + time: String? = nil, + updatedAt: String? = nil, + user: MessageUser? = nil, + fromMe: Bool? = nil, + messageType: MessageType? = nil, + status: MessageStatus? = nil, + contactForm: ContactForm? = nil + ) { + self.id = id + self.content = content + self.header = header + self.product = product + self.order = order + self.items = items + self.quoteContent = quoteContent + self.quoteID = quoteID + self.time = time + self.updatedAt = updatedAt + self.user = user + self.fromMe = fromMe + self.messageType = messageType + self.status = status + self.contactForm = contactForm + } + + private enum CodingKeys: String, CodingKey { + + case id + case content + case header + case product + case order + case items + case quoteContent = "quote_content" + case quoteID = "quote_id" + case time + case updatedAt = "updated_at" + case user + case fromMe = "from_me" + case messageType = "type" + case status + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decodeIfPresent(FlexibleID.self, forKey: .id) + content = try container.decodeIfPresent(String.self, forKey: .content) + header = try container.decodeIfPresent(String.self, forKey: .header) + product = try container.decodeIfPresent(MessageProduct.self, forKey: .product) + order = try container.decodeIfPresent(MessageOrder.self, forKey: .order) + items = try container.decodeIfPresent([MessageItem].self, forKey: .items) + quoteContent = try container.decodeIfPresent(String.self, forKey: .quoteContent) + quoteID = try container.decodeIfPresent(FlexibleID.self, forKey: .quoteID) + time = try container.decodeIfPresent(String.self, forKey: .time) + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + user = try container.decodeIfPresent(MessageUser.self, forKey: .user) + fromMe = try container.decodeIfPresent(Bool.self, forKey: .fromMe) + messageType = try container.decodeIfPresent(MessageType.self, forKey: .messageType) + status = try container.decodeIfPresent(MessageStatus.self, forKey: .status) + contactForm = nil + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(id, forKey: .id) + try container.encodeIfPresent(content, forKey: .content) + try container.encodeIfPresent(header, forKey: .header) + try container.encodeIfPresent(product, forKey: .product) + try container.encodeIfPresent(order, forKey: .order) + try container.encodeIfPresent(items, forKey: .items) + try container.encodeIfPresent(quoteContent, forKey: .quoteContent) + try container.encodeIfPresent(quoteID, forKey: .quoteID) + try container.encodeIfPresent(time, forKey: .time) + try container.encodeIfPresent(updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(user, forKey: .user) + try container.encodeIfPresent(fromMe, forKey: .fromMe) + try container.encodeIfPresent(messageType, forKey: .messageType) + try container.encodeIfPresent(status, forKey: .status) + } +} + +package extension ConsultantMessage { + + /// Whether this failed outgoing message contains a payload that can be sent again. + var canRetrySending: Bool { + guard id != nil, fromMe == true, status == .failed else { return false } + switch messageType { + case .text: + return content?.nilIfEmpty != nil + case .image, .file: + guard let items, !items.isEmpty else { return false } + return items.allSatisfy { $0.id?.nilIfEmpty != nil } + default: + return false + } + } + + /// Local identifier used for the welcome message created from consultant `intro_text`. + static let introMessageID = "_intro" + + /// Local identifier used for the offline message created from consultant offline configuration. + static let offlineMessageID = "_offline" + + /// Indicates whether this message is the local welcome message shown for an empty chat. + var isIntroMessage: Bool { + id?.value == Self.introMessageID + } + + /// Indicates whether this message is the local offline message shown while managers are unavailable. + var isOfflineMessage: Bool { + id?.value == Self.offlineMessageID + } + + /// Indicates whether this message is a local SDK support message, not a backend chat item. + var isVirtualSupportMessage: Bool { + isIntroMessage || isOfflineMessage + } + + /// Creates a local welcome text message from consultant `intro_text`. + static func introMessage(text: String) -> ConsultantMessage { + ConsultantMessage( + id: FlexibleID(introMessageID), + content: text, + time: nil, + user: MessageUser( + firstName: ConsultantLocalization.messageAuthorBotAssistant.localized + ), + fromMe: false, + messageType: .text, + status: .seen + ) + } + + /// Creates a local offline message from consultant offline configuration. + static func offlineMessage( + text: String?, + contactForm: ContactForm? + ) -> ConsultantMessage { + ConsultantMessage( + id: FlexibleID(offlineMessageID), + content: text, + time: nil, + user: MessageUser( + firstName: ConsultantLocalization.messageAuthorBotAssistant.localized + ), + fromMe: false, + messageType: contactForm == nil ? .text : .contactForm, + status: .seen, + contactForm: contactForm + ) + } + + /// Item identifiers that still need resolved file URLs. + var unresolvedFileLinkItemIDs: [String] { + guard messageType == .image || messageType == .file else { + return [] + } + + return items?.compactMap { item in + guard + item.resolvedFileURL == nil + else { + return nil + } + + return item.id + } ?? [] + } + + /// Display models for image message items. + var imageDisplayItems: [MessageFileDisplayItem] { + guard messageType == .image else { + return [] + } + + let fallbackDisplayName = ConsultantLocalization.messageItemImage.localized + let parsedItems = displayFileItems(fallbackDisplayName: fallbackDisplayName) + return parsedItems.isEmpty + ? [.fallback(displayName: fallbackDisplayName)] + : parsedItems + } + + /// Display models for file message items. + var fileDisplayItems: [MessageFileDisplayItem] { + guard messageType == .file else { + return [] + } + + let fallbackDisplayName = content?.nilIfEmpty + ?? ConsultantLocalization.commonFile.localized + let parsedItems = displayFileItems(fallbackDisplayName: fallbackDisplayName) + return parsedItems.isEmpty + ? [.fallback(displayName: fallbackDisplayName)] + : parsedItems + } + + /// Returns a copy of the message with a resolved file URL assigned to one item. + func applyingFileLink( + itemID: String, + url: String + ) -> ConsultantMessage { + guard let items else { + return self + } + + let updatedItems = items.map { item -> MessageItem in + guard item.id == itemID else { + return item + } + + return item.applyingFileLink(url) + } + + return ConsultantMessage( + id: id, + content: content, + header: header, + product: product, + order: order, + items: updatedItems, + quoteContent: quoteContent, + quoteID: quoteID, + time: time, + updatedAt: updatedAt, + user: user, + fromMe: fromMe, + messageType: messageType, + status: status, + contactForm: contactForm + ) + } + + /// Returns a copy of the message with the provided local contact form payload. + func applyingContactForm(_ contactForm: ContactForm?) -> ConsultantMessage { + ConsultantMessage( + id: id, + content: content, + header: header, + product: product, + order: order, + items: items, + quoteContent: quoteContent, + quoteID: quoteID, + time: time, + updatedAt: updatedAt, + user: user, + fromMe: fromMe, + messageType: messageType, + status: status, + contactForm: contactForm + ) + } + + /// Returns a copy of the message with the provided delivery/read status. + func applyingStatus(_ status: MessageStatus?) -> ConsultantMessage { + ConsultantMessage( + id: id, + content: content, + header: header, + product: product, + order: order, + items: items, + quoteContent: quoteContent, + quoteID: quoteID, + time: time, + updatedAt: updatedAt, + user: user, + fromMe: fromMe, + messageType: messageType, + status: status, + contactForm: contactForm + ) + } + + /// Returns a copy that keeps existing values for fields omitted by a realtime update. + func mergingMissingFields(from existing: ConsultantMessage) -> ConsultantMessage { + let message = ConsultantMessage( + id: id ?? existing.id, + content: content ?? existing.content, + header: header ?? existing.header, + product: product ?? existing.product, + order: order ?? existing.order, + items: items ?? existing.items, + quoteContent: quoteContent ?? existing.quoteContent, + quoteID: quoteID ?? existing.quoteID, + time: time ?? existing.time, + updatedAt: updatedAt ?? existing.updatedAt, + user: user ?? existing.user, + fromMe: fromMe ?? existing.fromMe, + messageType: messageType ?? existing.messageType, + status: status ?? existing.status, + contactForm: contactForm ?? existing.contactForm + ) + + return message.preservingResolvedFileLinks(from: existing) + } + + /// Returns a copy that keeps previously resolved file metadata when the backend omits it. + func preservingResolvedFileLinks(from existing: ConsultantMessage) -> ConsultantMessage { + guard messageType == .image || messageType == .file else { + return self + } + + guard let items, !items.isEmpty else { + guard let existingItems = existing.items, !existingItems.isEmpty else { + return self + } + + return ConsultantMessage( + id: id, + content: content, + header: header, + product: product, + order: order, + items: existingItems, + quoteContent: quoteContent, + quoteID: quoteID, + time: time, + updatedAt: updatedAt, + user: user, + fromMe: fromMe, + messageType: messageType, + status: status, + contactForm: contactForm + ) + } + + var existingItemsByID: [String: MessageItem] = [:] + for item in existing.items ?? [] { + guard + let id = item.id, + existingItemsByID[id] == nil + else { + continue + } + + existingItemsByID[id] = item + } + + guard !existingItemsByID.isEmpty else { + return self + } + + let updatedItems = items.map { item -> MessageItem in + guard + item.resolvedFileURL == nil, + let id = item.id, + let existingItem = existingItemsByID[id] + else { + return item + } + + return item.preservingResolvedFileLink(from: existingItem) + } + + guard updatedItems != items else { + return self + } + + return ConsultantMessage( + id: id, + content: content, + header: header, + product: product, + order: order, + items: updatedItems, + quoteContent: quoteContent, + quoteID: quoteID, + time: time, + updatedAt: updatedAt, + user: user, + fromMe: fromMe, + messageType: messageType, + status: status, + contactForm: contactForm + ) + } + + /// Parses raw file or image items into display models. + private func displayFileItems(fallbackDisplayName: String) -> [MessageFileDisplayItem] { + items?.compactMap { item in + return MessageFileDisplayItem( + id: item.id, + displayName: item.displayFileName ?? fallbackDisplayName, + sizeLabel: item.fileSizeLabel, + urlString: item.resolvedFileURL, + sourceImageSize: item.sourceImageSize + ) + } ?? [] + } +} + +package extension ConsultantMessage { + + /// Text copied from a message, including the visible contents of product and order cards. + var clipboardText: String? { + let lines: [String?] + switch messageType { + case .product where product != nil: + lines = [productDisplayName, productPriceLabel, productQuantityLabel] + case .order where order != nil: + lines = [orderNumberLabel] + + orderDisplayItems.map { item in + ["\(item.numberLabel) \(item.name)", item.details] + .filter { !$0.isEmpty } + .joined(separator: "\n") + } + + orderDisplayFields.map { "\($0.title): \($0.value)" } + default: + return content?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? content + : nil + } + + let text = lines.compactMap { $0 } + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .joined(separator: "\n") + return text.isEmpty ? nil : text + } + + /// Formatted order number for order messages. + var orderNumberLabel: String? { + order?.formattedOrderNumberLabel + } + + /// Display items for the order contents. + var orderDisplayItems: [MessageOrderDisplayItem] { + order?.displayItems ?? [] + } + + /// Display fields for the order summary. + var orderDisplayFields: [MessageOrderDisplayField] { + order?.displayFields ?? [] + } +} + +package extension ConsultantMessage { + + /// Product name for UI display with a default fallback. + var productDisplayName: String { + let fallbackName = ConsultantLocalization.messageItemProduct.localized + return product?.name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? product?.name ?? fallbackName + : fallbackName + } + + /// Formatted product price label. + var productPriceLabel: String? { + product?.cost?.formattedMoneyLabel + } + + /// Formatted product quantity label. + var productQuantityLabel: String? { + product?.quantity?.formattedQuantityLabel(defaultUnit: product?.unit) + } +} + +package extension ConsultantMessage { + + /// Quote preview shown above the message body. + var quotePreview: MessageQuotePreview? { + if let resolvedQuotePreview { + return resolvedQuotePreview + } + if let quoteText = quoteContent?.nilIfEmpty { + return .text(quoteText) + } + return quoteID == nil ? nil : .text(ConsultantLocalization.messageQuotePlaceholder.localized) + } +} + +// grouping +package extension ConsultantMessage { + + /// Indicates whether two adjacent messages can be grouped by sender. + func hasSameSender(as other: ConsultantMessage) -> Bool { + guard fromMe == other.fromMe else { + return false + } + + guard fromMe != true else { + return true + } + + return incomingSenderIdentity == other.incomingSenderIdentity + } + + /// Stable grouping identity for incoming sender data. + private var incomingSenderIdentity: String { + let firstName = normalizedSenderPart(user?.firstName) + let lastName = normalizedSenderPart(user?.lastName) + let avatar = normalizedSenderPart(user?.avatar) + let identity = [firstName, lastName, avatar].joined(separator: "|") + + return identity == "||" ? "incoming" : identity + } + + /// Normalizes one sender identity component. + private func normalizedSenderPart(_ value: String?) -> String { + value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } +} + +package extension ConsultantMessage { + + /// First remote or preinstalled image attached to a contact form. + var contactFormImage: ContactFormMessageImage? { + items?.compactMap { item in + if let url = ActionImage.remoteURL(from: item.resolvedFileURL) { + return ContactFormMessageImage( + url: url, + size: item.sourceImageSize + ) + } + + guard + let actionImage = ActionImage(preinstalledID: item.id), + let url = actionImage.preinstalledURL + else { + return nil + } + + return ContactFormMessageImage( + url: url, + size: actionImage.preinstalledSize + ) + }.first + } +} + +package extension ConsultantMessage { + + /// File preview items that can be opened from a file message. + var filePreviewItems: [FilePreviewItem] { + guard messageType == .file else { + return [] + } + + return fileDisplayItems.compactMap { item in + guard + let urlString = item.urlString, + let url = URL(string: urlString) + else { + return nil + } + + return FilePreviewItem( + id: item.id, + url: url, + displayName: item.displayName, + sizeLabel: item.sizeLabel + ) + } + } + + /// Image preview items that can be opened from an image message. + var imagePreviewItems: [ImagePreviewItem] { + guard messageType == .image else { + return [] + } + + return imageDisplayItems.compactMap { item in + guard + let urlString = item.urlString, + let url = URL(string: urlString) + else { + return nil + } + + return ImagePreviewItem( + id: item.id, + url: url, + caption: item.displayName + ) + } + } +} + +package extension ConsultantMessage { + + /// Parsed date used to sort messages in the chat. + var chatSortDate: Date? { + guard let value = time ?? updatedAt else { + return nil + } + + return DateParser.date(from: value) + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageItem.swift b/Sources/OnlineConsultant/Models/Message/MessageItem.swift new file mode 100644 index 0000000..37174ba --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageItem.swift @@ -0,0 +1,209 @@ +import CoreGraphics +import Foundation + +/// File or image item from backend `message.items`. +package struct MessageItem: Codable, Equatable, Sendable { + + /// Uploaded file identifier from backend `items[].id`. + package let id: String? + + /// File caption from backend `items[].caption`. + package let caption: String? + + /// File size in bytes from backend `items[].size`. + package let size: Int64? + + /// Image width in pixels from backend `items[].width`. + package let width: Double? + + /// Image height in pixels from backend `items[].height`. + package let height: Double? + + /// File or image URL from backend `items[].url`. + /// + /// The SDK also fills this value locally after resolving a file link by `id`. + package let url: String? + + /// Creates a typed file or image message item. + package init( + id: String? = nil, + caption: String? = nil, + size: Int64? = nil, + width: Double? = nil, + height: Double? = nil, + url: String? = nil + ) { + self.id = id?.nilIfEmpty + self.caption = caption?.nilIfEmpty + self.size = size + self.width = width + self.height = height + self.url = url?.nilIfEmpty + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decodeFlexibleStringIfPresent(forKey: .id) + caption = try container.decodeIfPresent(String.self, forKey: .caption)?.nilIfEmpty + size = try container.decodeFlexibleInt64IfPresent(forKey: .size) + width = try container.decodeFlexibleDoubleIfPresent(forKey: .width) + height = try container.decodeFlexibleDoubleIfPresent(forKey: .height) + url = try container.decodeIfPresent(String.self, forKey: .url)?.nilIfEmpty + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(id, forKey: .id) + try container.encodeIfPresent(caption, forKey: .caption) + try container.encodeIfPresent(size, forKey: .size) + try container.encodeIfPresent(width, forKey: .width) + try container.encodeIfPresent(height, forKey: .height) + try container.encodeIfPresent(url, forKey: .url) + } + + private enum CodingKeys: String, CodingKey { + + case id + case caption + case size + case width + case height + case url + } +} + +package extension MessageItem { + + var resolvedFileURL: String? { + url + } + + var sourceImageSize: CGSize? { + guard + let width, + let height, + width > 0, + height > 0 + else { + return nil + } + + return CGSize(width: width, height: height) + } + + var displayFileName: String? { + caption ?? id + } + + var fileSizeLabel: String? { + guard let size else { + return nil + } + + return Self.formattedFileSize(size) + } + + func applyingFileLink(_ url: String) -> MessageItem { + .init( + id: id, + caption: caption, + size: size, + width: width, + height: height, + url: url + ) + } + + func preservingResolvedFileLink(from existing: MessageItem) -> MessageItem { + .init( + id: id, + caption: caption ?? existing.caption, + size: size ?? existing.size, + width: width ?? existing.width, + height: height ?? existing.height, + url: url ?? existing.url + ) + } + + private static func formattedFileSize(_ bytes: Int64) -> String { + guard bytes >= 0 else { + return "0 B" + } + + let units = ["B", "KB", "MB", "GB"] + var value = Double(bytes) + var unitIndex = 0 + + while value >= 1_000, unitIndex < units.count - 1 { + value /= 1_000 + unitIndex += 1 + } + + if unitIndex == 0 || value >= 10 { + return "\(Int(value.rounded())) \(units[unitIndex])" + } + + return String(format: "%.1f %@", value, units[unitIndex]) + } +} + +private extension KeyedDecodingContainer { + + func decodeFlexibleStringIfPresent(forKey key: Key) throws -> String? { + if let value = try decodeIfPresent(String.self, forKey: key) { + return value.nilIfEmpty + } + + if let value = try decodeIfPresent(Int64.self, forKey: key) { + return String(value) + } + + if let value = try decodeIfPresent(Double.self, forKey: key) { + guard value.isFinite else { + return nil + } + + if value.rounded() == value { + return String(Int64(value)) + } + + return String(value) + } + + return nil + } + + func decodeFlexibleInt64IfPresent(forKey key: Key) throws -> Int64? { + if let value = try decodeIfPresent(Int64.self, forKey: key) { + return value + } + + if let value = try decodeIfPresent(Double.self, forKey: key) { + guard value.isFinite else { + return nil + } + + return Int64(value) + } + + if let value = try decodeIfPresent(String.self, forKey: key) { + return Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + return nil + } + + func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? { + if let value = try decodeIfPresent(Double.self, forKey: key) { + return value.isFinite ? value : nil + } + + if let value = try decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + return nil + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrder.swift b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrder.swift new file mode 100644 index 0000000..afa6276 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrder.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Order payload attached to an order message. +package struct MessageOrder: Codable, Equatable, Sendable { + + /// Customer-visible order number from backend `order.number`. + package let number: FlexibleID? + + /// Primary order total payload from backend `order.cost`. + package let cost: MessageProductCost? + + /// Order item list from backend `order.items`. + package let items: [MessageOrderItem] + + /// Delivery payload from backend `order.delivery`. + package let delivery: MessageOrderDelivery? + + /// Discount payload from backend `order.discount`. + package let discount: MessageProductCost? + + /// Payment list from backend `order.payments`. + package let payments: [MessageOrderPayment] + + /// Creates an order payload for an order message. + package init( + number: FlexibleID? = nil, + cost: MessageProductCost? = nil, + items: [MessageOrderItem] = [], + delivery: MessageOrderDelivery? = nil, + discount: MessageProductCost? = nil, + payments: [MessageOrderPayment] = [] + ) { + self.number = number + self.cost = cost + self.items = items + self.delivery = delivery + self.discount = discount + self.payments = payments + } + + /// Decodes an order payload, defaulting missing item and payment arrays to empty arrays. + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.number = try container.decodeIfPresent(FlexibleID.self, forKey: .number) + self.cost = try container.decodeIfPresent(MessageProductCost.self, forKey: .cost) + self.items = try container.decodeIfPresent([MessageOrderItem].self, forKey: .items) ?? [] + self.delivery = try container.decodeIfPresent(MessageOrderDelivery.self, forKey: .delivery) + self.discount = try container.decodeIfPresent(MessageProductCost.self, forKey: .discount) + self.payments = try container.decodeIfPresent([MessageOrderPayment].self, forKey: .payments) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case number + case cost + case items + case delivery + case discount + case payments + } +} + +package extension MessageOrder { + + /// Formatted order number label for UI display. + var formattedOrderNumberLabel: String? { + guard let number = number?.value.nilIfEmpty else { + return nil + } + + return ConsultantLocalization.orderLabelNumber.format(number) + } + + /// Order items prepared for UI display. + var displayItems: [MessageOrderDisplayItem] { + items.enumerated().compactMap { index, item in + guard let name = item.name?.nilIfEmpty else { + return nil + } + + return .init( + numberLabel: "\(index + 1).", + name: name, + details: item.formattedDetails, + url: item.url.flatMap(URL.init(string:)) + ) + } + } + + /// Order summary fields prepared for UI display. + var displayFields: [MessageOrderDisplayField] { + var fields: [MessageOrderDisplayField] = [] + + if let deliveryName = delivery?.name?.nilIfEmpty { + fields.append(.init( + title: ConsultantLocalization.orderFieldDelivery.localized, + value: deliveryName + )) + } + + if let address = delivery?.address?.nilIfEmpty { + fields.append(.init( + title: ConsultantLocalization.orderFieldAddress.localized, + value: address + )) + } + + if let deliveryComment = delivery?.comment?.nilIfEmpty { + fields.append(.init( + title: ConsultantLocalization.orderFieldComment.localized, + value: deliveryComment + )) + } + + if let deliveryPrice = delivery?.price?.formattedMoneyLabel { + fields.append(.init( + title: ConsultantLocalization.orderFieldDeliveryCost.localized, + value: deliveryPrice + )) + } + + if let discount = discount?.formattedMoneyLabel { + fields.append(.init( + title: ConsultantLocalization.orderFieldDiscount.localized, + value: discount + )) + } + + if let total = cost?.formattedMoneyLabel { + fields.append(.init( + title: ConsultantLocalization.orderFieldTotal.localized, + value: total + )) + } + + if let payment = formattedPaymentLabel { + fields.append(.init( + title: ConsultantLocalization.orderFieldPayment.localized, + value: payment + )) + } + + return fields + } + + /// Formatted payment label built from backend `order.payments`. + private var formattedPaymentLabel: String? { + let paymentLabels = payments.compactMap(\.formattedLabel) + return paymentLabels.isEmpty ? nil : paymentLabels.joined(separator: "\n") + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderDelivery.swift b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderDelivery.swift new file mode 100644 index 0000000..fedf4f8 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderDelivery.swift @@ -0,0 +1,35 @@ +// +// MessageOrderDelivery.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Delivery payload attached to an order message. +package struct MessageOrderDelivery: Codable, Equatable, Sendable { + + /// Delivery address from backend `order.delivery.address`. + package let address: String? + + /// Delivery comment from backend `order.delivery.comment`. + package let comment: String? + + /// Delivery method name from backend `order.delivery.name`. + package let name: String? + + /// Delivery price from backend `order.delivery.price`. + package let price: MessageProductCost? + + /// Creates a delivery payload for an order message. + package init( + address: String? = nil, + comment: String? = nil, + name: String? = nil, + price: MessageProductCost? = nil + ) { + self.address = address + self.comment = comment + self.name = name + self.price = price + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderItem.swift b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderItem.swift new file mode 100644 index 0000000..c03f01f --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderItem.swift @@ -0,0 +1,56 @@ +// +// MessageOrderItem.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Order item payload attached to an order message. +package struct MessageOrderItem: Codable, Equatable, Sendable { + + /// Product name from backend `order.items[].name`. + package let name: String? + + /// Product URL from backend `order.items[].url`. + package let url: String? + + /// Item price from backend `order.items[].price`. + package let price: MessageProductCost? + + /// Item quantity from backend `order.items[].quantity`. + package let quantity: MessageProductQuantity? + + /// Creates an order item payload. + package init( + name: String? = nil, + url: String? = nil, + price: MessageProductCost? = nil, + quantity: MessageProductQuantity? = nil + ) { + self.name = name + self.url = url + self.price = price + self.quantity = quantity + } + + private enum CodingKeys: String, CodingKey { + case name + case url + case price + case quantity + } +} + +package extension MessageOrderItem { + + /// Formatted quantity and price details used by order message UI. + var formattedDetails: String { + let quantityLabel = quantity?.formattedOrderQuantityLabel(defaultUnit: nil) + let priceLabel = price?.formattedMoneyLabel.map { "x \($0)" } + let value = [quantityLabel, priceLabel] + .compactMap { $0 } + .joined(separator: " ") + + return value + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPayment.swift b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPayment.swift new file mode 100644 index 0000000..9e8bc69 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPayment.swift @@ -0,0 +1,46 @@ +// +// MessageOrderPayment.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Payment payload attached to an order message. +package struct MessageOrderPayment: Codable, Equatable, Sendable { + + /// Payment amount from backend `order.payments[].amount`. + package let amount: MessageProductCost? + + /// Payment method name from backend `order.payments[].name`. + package let name: String? + + /// Payment status payload from backend `order.payments[].status`. + package let status: MessageOrderPaymentStatus? + + /// Creates a payment payload for an order message. + package init( + amount: MessageProductCost? = nil, + name: String? = nil, + status: MessageOrderPaymentStatus? = nil + ) { + self.amount = amount + self.name = name + self.status = status + } +} + +package extension MessageOrderPayment { + + /// Formatted payment label used by order message UI. + var formattedLabel: String? { + let label = [ + name?.nilIfEmpty, + amount?.formattedMoneyLabel, + status?.name?.nilIfEmpty + ] + .compactMap { $0 } + .joined(separator: " ") + + return label.isEmpty ? nil : label + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPaymentStatus.swift b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPaymentStatus.swift new file mode 100644 index 0000000..06e900e --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageOrder/MessageOrderPaymentStatus.swift @@ -0,0 +1,25 @@ +// +// MessageOrderPaymentStatus.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Payment status payload attached to an order payment. +package struct MessageOrderPaymentStatus: Codable, Equatable, Sendable { + + /// Payment status display name from backend `order.payments[].status.name`. + package let name: String? + + /// Payment execution indicator from backend `order.payments[].status.payed`. + package let payed: Bool? + + /// Creates a payment status payload for an order message. + package init( + name: String? = nil, + payed: Bool? = nil + ) { + self.name = name + self.payed = payed + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProduct.swift b/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProduct.swift new file mode 100644 index 0000000..5ed1bc9 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProduct.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Product payload attached to a product message. +package struct MessageProduct: Codable, Equatable, Sendable { + + /// Product display name from backend `product.name`. + package let name: String? + + /// Product page URL from backend `product.url`. + package let url: String? + + /// Product image URL from backend `product.img`. + package let img: String? + + /// Product price payload from backend `product.cost`. + package let cost: MessageProductCost? + + /// Fallback unit label from backend `product.unit`, used when `product.quantity.unit` is empty. + package let unit: String? + + /// Product quantity and unit payload from backend `product.quantity`. + package let quantity: MessageProductQuantity? + + /// Creates a product payload for a product message. + package init( + name: String? = nil, + url: String? = nil, + img: String? = nil, + cost: MessageProductCost? = nil, + unit: String? = nil, + quantity: MessageProductQuantity? = nil + ) { + self.name = name + self.url = url + self.img = img + self.cost = cost + self.unit = unit + self.quantity = quantity + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductCost.swift b/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductCost.swift new file mode 100644 index 0000000..b77a82b --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductCost.swift @@ -0,0 +1,56 @@ +// +// MessageProductCost.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Foundation + +/// Product or order monetary amount payload. +package struct MessageProductCost: Codable, Equatable, Sendable { + + /// Numeric amount from backend `cost.value`. + package let value: Double? + + /// Currency code from backend `cost.currency`, usually an ISO 4217 code such as `RUB` or `USD`. + package let currency: String? + + /// Creates a monetary amount payload. + package init( + value: Double? = nil, + currency: String? = nil + ) { + self.value = value + self.currency = currency + } +} + +package extension MessageProductCost { + + /// Localized money label used by product and order message UI. + var formattedMoneyLabel: String? { + guard let value else { + return nil + } + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + + if let currency = currency?.trimmingCharacters(in: .whitespacesAndNewlines), !currency.isEmpty { + formatter.currencyCode = currency + } + + if let label = formatter.string(from: NSNumber(value: value)) { + return label + } + + let valueLabel = String(format: "%.2f", value) + let currencyLabel = currency?.trimmingCharacters(in: .whitespacesAndNewlines) + + return [valueLabel, currencyLabel] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductQuantity.swift b/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductQuantity.swift new file mode 100644 index 0000000..c476d81 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageProduct/MessageProductQuantity.swift @@ -0,0 +1,90 @@ +// +// MessageProductQuantity.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Product or order item quantity payload. +package struct MessageProductQuantity: Codable, Equatable, Sendable { + + /// Numeric quantity from backend `quantity.value`. + package let value: Double? + + /// Display unit from backend `quantity.unit`, such as `pcs` or a localized catalog unit. + package let unit: String? + + /// Creates a quantity payload. + package init( + value: Double? = nil, + unit: String? = nil + ) { + self.value = value + self.unit = unit + } +} + +package extension MessageProductQuantity { + + /// Formats quantity for order item rows, using the fallback unit when `unit` is empty. + func formattedOrderQuantityLabel(defaultUnit: String?) -> String? { + guard let value else { + return nil + } + + let amountLabel: String + if value.truncatingRemainder(dividingBy: 1) == 0 { + amountLabel = String(Int64(value)) + } else { + amountLabel = String(format: "%.2f", value) + .replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression) + .replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression) + } + + let unitLabel = unit?.nilIfEmpty ?? defaultUnit?.nilIfEmpty + return [amountLabel, unitLabel] + .compactMap { $0 } + .joined(separator: " ") + } +} + +package extension MessageProductQuantity { + + /// Indicates whether the quantity represents an available product. + var isAvailable: Bool { + (value ?? 0) > 0 + } + + /// Formats quantity for product messages, including the availability text. + func formattedQuantityLabel(defaultUnit: String?) -> String? { + guard let value else { + return nil + } + + guard value > 0 else { + return ConsultantLocalization.productQuantityUnavailable.localized + } + + let amountLabel: String + if value.truncatingRemainder(dividingBy: 1) == 0 { + amountLabel = String(Int64(value)) + } else { + amountLabel = String(format: "%.2f", value) + .replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression) + .replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression) + } + + let unitLabel = unit?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? unit + : defaultUnit + + return [ + ConsultantLocalization.productQuantityAvailable.localized, + amountLabel, + unitLabel + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageStatus.swift b/Sources/OnlineConsultant/Models/Message/MessageStatus.swift new file mode 100644 index 0000000..64b541d --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageStatus.swift @@ -0,0 +1,106 @@ +// +// MessageStatus.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// Message delivery status used by backend messages and SDK-generated local messages. +package enum MessageStatus: Codable, Equatable, Sendable, ExpressibleByStringLiteral { + + /// Backend zero status. + case none + + /// Backend status for a newly created message before transport confirmation. + case new + + /// Backend status for a message accepted by the transport layer. + case sent + + /// Backend status for a message seen by the recipient. + case seen + + /// Backend status for a deleted message. + case deleted + + /// Backend or local status for a message that failed to send. + case failed + + /// Frontend-compatible local status used before a real backend status is known. + case undefined + + /// Local status for an outgoing message that is still being sent. + case sending + + /// Frontend-compatible local status for synthetic messages that are already display-ready. + case delivered + + /// Message status that is not known by this SDK version. + case unknown(String) + + /// String value used in backend JSON payloads and local SDK payloads. + package var rawValue: String { + switch self { + case .none: + return "none" + case .new: + return "new" + case .sent: + return "sent" + case .seen: + return "seen" + case .deleted: + return "deleted" + case .failed: + return "failed" + case .undefined: + return "undefined" + case .sending: + return "sending" + case .delivered: + return "delivered" + case .unknown(let value): + return value + } + } + + /// Creates a message status from a backend or local SDK string value. + package init(rawValue: String) { + switch rawValue { + case "none": + self = .none + case "new": + self = .new + case "sent": + self = .sent + case "seen": + self = .seen + case "deleted": + self = .deleted + case "failed": + self = .failed + case "undefined": + self = .undefined + case "sending": + self = .sending + case "delivered": + self = .delivered + default: + self = .unknown(rawValue) + } + } + + package init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageType.swift b/Sources/OnlineConsultant/Models/Message/MessageType.swift new file mode 100644 index 0000000..a9d0200 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageType.swift @@ -0,0 +1,99 @@ +// +// MessageType.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 17.07.2026. +// + +/// Message type used by backend messages and SDK-generated local messages. +package enum MessageType: Codable, Equatable, Sendable, ExpressibleByStringLiteral { + + /// Plain text message. + case text + + /// Backend system message. + case system + + /// Backend command message. + case command + + /// Order message. + case order + + /// Product message. + case product + + /// File attachment message. + case file + + /// Image attachment message. + case image + + /// Local SDK contact form message generated from site action data. + case contactForm + + /// Message type that is not known by this SDK version. + case unknown(String) + + /// String value used in backend JSON payloads and local SDK payloads. + package var rawValue: String { + switch self { + case .text: + return "text" + case .system: + return "system" + case .command: + return "command" + case .order: + return "order" + case .product: + return "product" + case .file: + return "file" + case .image: + return "image" + case .contactForm: + return "contact_form" + case .unknown(let value): + return value + } + } + + /// Creates a message type from a backend or local SDK string value. + package init(rawValue: String) { + switch rawValue { + case "text": + self = .text + case "system": + self = .system + case "command": + self = .command + case "order": + self = .order + case "product": + self = .product + case "file": + self = .file + case "image": + self = .image + case "contact_form": + self = .contactForm + default: + self = .unknown(rawValue) + } + } + + package init(stringLiteral value: StringLiteralType) { + self.init(rawValue: value) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Sources/OnlineConsultant/Models/Message/MessageUser.swift b/Sources/OnlineConsultant/Models/Message/MessageUser.swift new file mode 100644 index 0000000..23c8219 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/MessageUser.swift @@ -0,0 +1,57 @@ +// +// MessageUser.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Sender payload attached to a consultant message. +package struct MessageUser: Codable, Equatable, Sendable { + + /// Sender first name from backend `message.user.first_name`. + package let firstName: String? + + /// Sender last name from backend `message.user.last_name`. + package let lastName: String? + + /// Sender avatar URL from backend `message.user.avatar`. + package let avatar: String? + + /// Creates a message sender payload. + package init( + firstName: String? = nil, + lastName: String? = nil, + avatar: String? = nil + ) { + self.firstName = firstName + self.lastName = lastName + self.avatar = avatar + } + + private enum CodingKeys: String, CodingKey { + + case firstName = "first_name" + case lastName = "last_name" + case avatar + } +} + +package extension MessageUser { + + func displayName(isOutgoing: Bool) -> String { + let name = [firstName, lastName] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " ") + + return name.isEmpty + ? Self.fallbackDisplayName(isOutgoing: isOutgoing) + : name + } + + static func fallbackDisplayName(isOutgoing: Bool) -> String { + isOutgoing + ? ConsultantLocalization.messageAuthorVisitor.localized + : ConsultantLocalization.messageAuthorConsultant.localized + } +} diff --git a/Sources/OnlineConsultant/Models/Message/SiteAction/SiteAction.swift b/Sources/OnlineConsultant/Models/Message/SiteAction/SiteAction.swift new file mode 100644 index 0000000..5169614 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/SiteAction/SiteAction.swift @@ -0,0 +1,234 @@ +// +// SiteAction.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Foundation + +/// Site action returned by the connect response. +/// +/// Backend actions are configured for both the browser widget and mobile SDKs. The iOS SDK supports +/// immediate `contact_form` actions, including form content, image, retry state, mobile device +/// filtering, weekday filtering, and user-local time filtering. Browser-specific triggers are not +/// exposed by this model. +package struct SiteAction: Decodable, Equatable, Sendable { + + /// Action identifier used when marking the action as executed. + package let id: FlexibleID? + + /// Backend action type. + package let type: SiteActionType? + + /// Optional title displayed above the contact form. + package let header: String? + + /// Message texts configured for the action. + package let messages: [String] + + /// Contact form field configuration for `contact_form` actions. + package let contactForm: [ContactFormFieldKey: ContactFormField]? + + /// Remote image URL or preinstalled image identifier attached to the contact form. + package let image: String? + + /// Indicates whether the action can be executed again after a previous execution. + private let allowRetry: Bool + + /// Last execution state for retryable actions. + private let lastExecution: SiteActionExecution? + + /// Target device from backend `user_device`. iOS treats empty, `any`, and `mobile` as supported. + private let userDevice: String? + + /// Weekday keys when the action is limited to specific local weekdays. + private let weekdays: [String] + + /// Start of the supported local user-time interval in `HH:mm` format. + private let userCurrentTimeFrom: String? + + /// End of the supported local user-time interval in `HH:mm` format. + private let userCurrentTimeTo: String? + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(FlexibleID.self, forKey: .id) + type = try container.decodeIfPresent(SiteActionType.self, forKey: .type) + header = try container.decodeIfPresent(String.self, forKey: .header) + messages = try container.decodeIfPresent([String].self, forKey: .messages) ?? [] + contactForm = try container.decodeIfPresent( + [String: ContactFormField].self, + forKey: .contactForm + ).map(Dictionary.init(backendFields:)) + image = try container.decodeIfPresent(String.self, forKey: .image) + allowRetry = try container.decodeIfPresent(Bool.self, forKey: .allowRetry) ?? false + lastExecution = try container.decodeIfPresent(SiteActionExecution.self, forKey: .lastExecution) + userDevice = try container.decodeIfPresent(String.self, forKey: .userDevice) + weekdays = try container.decodeIfPresent([String].self, forKey: .weekdays) ?? [] + userCurrentTimeFrom = try container.decodeIfPresent(String.self, forKey: .userCurrentTimeFrom) + userCurrentTimeTo = try container.decodeIfPresent(String.self, forKey: .userCurrentTimeTo) + } + + private enum CodingKeys: String, CodingKey { + case id + case type + case header + case messages + case contactForm = "contact_form" + case image + case allowRetry = "allow_retry" + case lastExecution = "last_execution" + case userDevice = "user_device" + case weekdays + case userCurrentTimeFrom = "user_current_time_from" + case userCurrentTimeTo = "user_current_time_to" + } +} + +package extension SiteAction { + + /// Resolved action image URL, either remote or bundled preinstalled resource. + var imageURL: URL? { + ActionImage.remoteURL(from: image) + ?? actionImage?.preinstalledURL + } + + /// Preinstalled image identifier when `image` points to a bundled action image. + var imageID: String? { + actionImage?.preinstalledID + } + + /// Preinstalled bundled action image when `image` contains a known backend image id. + var actionImage: ActionImage? { + .init(preinstalledID: image) + } + + /// Image items for a contact-form action. + var messageItems: [MessageItem] { + if let imageID { + return [MessageItem(id: imageID)] + } + + guard let imageURL = imageURL?.absoluteString.nilIfEmpty else { + return [] + } + + return [MessageItem(url: imageURL)] + } +} + +package extension SiteAction { + + /// Indicates whether this action is a supported immediate contact form action. + var isSafeImmediateContactFormAction: Bool { + type == .contactForm + && isAllowedByRetryPolicy + && isAllowedForCurrentDevice + && isAllowedForCurrentWeekday + && isAllowedForCurrentUserTime + } + + /// Contact form fields decoded from the backend `contact_form` object. + var contactFormFields: [ContactFormFieldKey: ContactFormField] { + contactForm ?? [:] + } + + /// Returns whether the action still needs to show a contact form for the current user. + func requiresContactForm(for user: ContactFormUser) -> Bool { + guard isSafeImmediateContactFormAction else { + return false + } + + let fields = contactFormFields + guard !fields.isEmpty else { + return false + } + + return fields.contains { field, config in + switch field { + case .personalData: + return false + case .marketingConsent: + return config.required + default: + return config.required && !user.hasFilledValue(for: field) + } + } + } + + /// Builds a local contact form model for a supported contact form action. + func makeContactForm(for user: ContactFormUser) -> ContactForm? { + guard requiresContactForm(for: user) else { + return nil + } + + return ContactForm( + text: messages + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + .nilIfEmpty, + display: .atBeginningDialogue, + fields: contactFormFields, + prefill: ContactFormPrefill( + name: user.name, + email: user.email, + phone: user.phone + ), + actionID: id + ) + } + + private var isAllowedByRetryPolicy: Bool { + lastExecution == nil || allowRetry + } + + private var isAllowedForCurrentDevice: Bool { + guard let value = userDevice else { + return true + } + + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalizedValue.isEmpty || normalizedValue == "any" || normalizedValue == "mobile" + } + + private var isAllowedForCurrentWeekday: Bool { + guard !weekdays.isEmpty else { + return true + } + + let weekdayNumbers = Set(weekdays.compactMap(\.weekdayNumber)) + guard !weekdayNumbers.isEmpty else { + return false + } + + return weekdayNumbers.contains(Calendar.current.component(.weekday, from: Date()) - 1) + } + + private var isAllowedForCurrentUserTime: Bool { + let fromValue = userCurrentTimeFrom?.timeMinutes + let toValue = userCurrentTimeTo?.timeMinutes + + switch (fromValue, toValue) { + case (nil, nil): + return true + case let (.some(fromMinutes), .some(toMinutes)): + let now = Date() + let components = Calendar.current.dateComponents([.hour, .minute], from: now) + let currentMinutes = (components.hour ?? 0) * 60 + (components.minute ?? 0) + + if fromMinutes == toMinutes { + return true + } + + if fromMinutes < toMinutes { + return currentMinutes >= fromMinutes && currentMinutes <= toMinutes + } + + return currentMinutes >= fromMinutes || currentMinutes <= toMinutes + default: + return false + } + } +} diff --git a/Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionExecution.swift b/Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionExecution.swift new file mode 100644 index 0000000..c0ebf0c --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionExecution.swift @@ -0,0 +1,38 @@ +// +// SiteActionExecution.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Last execution state for a retryable site action. +/// +/// The backend includes this object in `last_execution` only for actions that allow retry. Frontend +/// uses it to decide whether and when the action can be shown again. +package struct SiteActionExecution: Decodable, Equatable, Sendable { + + /// Identifier of the last action execution record. + package let id: FlexibleID? + + /// Result assigned to the last execution, for example `close_consultant`, `go_to_page`, or `answer`. + package let result: String? + + /// Number of seconds since `result` was assigned on the backend. + private let secSinceResult: Int? + + private enum CodingKeys: String, CodingKey { + case id + case result + case secSinceResult = "sec_since_result" + } + + package init( + id: FlexibleID?, + result: String?, + secSinceResult: Int? + ) { + self.id = id + self.result = result + self.secSinceResult = secSinceResult + } +} diff --git a/Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionType.swift b/Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionType.swift new file mode 100644 index 0000000..807ce48 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Message/SiteAction/SiteActionType.swift @@ -0,0 +1,28 @@ +// +// SiteActionType.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 20.07.2026. +// + +/// Backend site action type. +package enum SiteActionType: Decodable, Equatable, Sendable { + + /// Contact form action. + case contactForm + + /// Action type that is not supported by the current SDK. + case unknown(String) + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + + switch value { + case "contact_form": + self = .contactForm + default: + self = .unknown(value) + } + } +} diff --git a/Sources/OnlineConsultant/Models/Realtime/ConsultantOfflineStatus.swift b/Sources/OnlineConsultant/Models/Realtime/ConsultantOfflineStatus.swift new file mode 100644 index 0000000..74545ae --- /dev/null +++ b/Sources/OnlineConsultant/Models/Realtime/ConsultantOfflineStatus.swift @@ -0,0 +1,19 @@ +/// Realtime payload sent by the backend when consultant availability changes. +/// +/// The event uses the `offline_status` type and contains only `is_offline`. The same offline flag +/// also appears in the connect response behaviour, but this model represents the websocket payload. + package struct ConsultantOfflineStatus: Codable, Equatable, Sendable { + + /// Indicates whether managers for the current site are offline. + package let isOffline: Bool + + /// Creates an offline status payload. + package init(isOffline: Bool) { + self.isOffline = isOffline + } + + private enum CodingKeys: String, CodingKey { + + case isOffline = "is_offline" + } +} diff --git a/Sources/OnlineConsultant/Models/Realtime/ConsultantRealtimeEvent.swift b/Sources/OnlineConsultant/Models/Realtime/ConsultantRealtimeEvent.swift new file mode 100644 index 0000000..3b7ec22 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Realtime/ConsultantRealtimeEvent.swift @@ -0,0 +1,81 @@ +// +// ConsultantRealtimeEvent.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Foundation + +/// Realtime event envelope received from the consultant backend. +/// +/// Backend websocket events are JSON objects with `type` and `data` fields. Current backend emits +/// `message` and `offline_status`; unknown event types are preserved so the SDK can ignore them +/// without breaking the realtime stream, matching the web widget behaviour. + package enum ConsultantRealtimeEvent: Decodable, Equatable, Sendable { + + /// Chat message event with a backend message payload. + case message(ConsultantMessage) + + /// Manager availability event with an offline status payload. + case offlineStatus(ConsultantOfflineStatus) + + /// Unsupported event type with its raw JSON payload. + case unknown(type: String, data: ConsultantJSONValue?) + + /// Decodes a realtime event from a websocket text frame. + package init(rawText: String) throws { + let data = Data(rawText.utf8) + self = try JSONDecoder().decode(ConsultantRealtimeEvent.self, from: data) + } + + /// Backend event type string from the event envelope. + package var type: String { + switch self { + case .message: + return ConsultantRealtimeEventType.message.rawValue + case .offlineStatus: + return ConsultantRealtimeEventType.offlineStatus.rawValue + case .unknown(let type, _): + return type + } + } + + private enum CodingKeys: String, CodingKey { + + case type + case data + } + + /// Decodes the backend `type` and dispatches `data` to the matching payload model. + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case ConsultantRealtimeEventType.message.rawValue: + self = .message( + try container.decode(ConsultantMessage.self, forKey: .data) + ) + case ConsultantRealtimeEventType.offlineStatus.rawValue: + self = .offlineStatus( + try container.decode(ConsultantOfflineStatus.self, forKey: .data) + ) + default: + self = .unknown( + type: type, + data: try container.decodeIfPresent(ConsultantJSONValue.self, forKey: .data) + ) + } + } +} + +/// Known backend realtime event type values. + private enum ConsultantRealtimeEventType: String, Codable, Equatable, Sendable { + + /// Chat message event. + case message + + /// Manager availability event. + case offlineStatus = "offline_status" +} diff --git a/Sources/OnlineConsultant/Models/Requests/ConsultantContactFormRequest.swift b/Sources/OnlineConsultant/Models/Requests/ConsultantContactFormRequest.swift new file mode 100644 index 0000000..ca81562 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Requests/ConsultantContactFormRequest.swift @@ -0,0 +1,36 @@ +// +// ConsultantContactFormRequest.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Request body for `POST /api/client/v1/contact/form`. +/// +/// The model matches the backend `ContactFormRequest` JSON contract. Required-field validation is +/// controlled by backend site/action settings and is not sent in the request body. +package struct ConsultantContactFormRequest: Encodable, Equatable { + + /// Customer name sent as `name`. + package let name: String? + + /// Customer email sent as `email`. + package let email: String? + + /// Customer phone sent as `phone`. + package let phone: String? + + /// Customer comment sent as `comment`. + package let comment: String? + + /// Optional marketing consent flag sent as `marketingConsent`. + package let marketingConsent: Bool? + + package init(data: ContactFormSubmitData) { + self.name = data.name?.nilIfEmpty + self.email = data.email?.nilIfEmpty + self.phone = data.phone?.nilIfEmpty + self.comment = data.comment?.nilIfEmpty + self.marketingConsent = data.marketingConsent + } +} diff --git a/Sources/OnlineConsultant/Models/Requests/ConsultantSendMessageRequest.swift b/Sources/OnlineConsultant/Models/Requests/ConsultantSendMessageRequest.swift new file mode 100644 index 0000000..9815a7a --- /dev/null +++ b/Sources/OnlineConsultant/Models/Requests/ConsultantSendMessageRequest.swift @@ -0,0 +1,80 @@ +// +// ConsultantSendMessageRequest.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Core + +/// Backend request for `POST /api/client/v1/messages`. +package struct ConsultantSendMessageRequest: Encodable, Equatable { + + /// Message payload sent in the required `message` field. + package let message: ConsultantSendMessageBody + + /// User contact data sent in the optional `user` field. + package let user: ConnectUser + + package init( + message: ConsultantSendMessageBody, + user: ConnectUser = ConnectUser() + ) { + self.message = message + self.user = user + } + + private enum CodingKeys: String, CodingKey { + case message + case user + } +} + +/// Message body for `ConsultantSendMessageRequest.message`. +package struct ConsultantSendMessageBody: Encodable, Equatable { + + /// Backend message type sent in `message.type`. + package let type: MessageType + + /// Text content sent in `message.text`. + package let text: String? + + /// File/image items sent in `message.items`. + package let items: [ConsultantSendMessageItem]? + + package init( + type: MessageType, + text: String? = nil, + items: [ConsultantSendMessageItem]? = nil + ) { + self.type = type + self.text = text + self.items = items + } + + private enum CodingKeys: String, CodingKey { + case type + case text + case items + } +} + +/// File or image item sent in `message.items`. +/// +/// The backend request item contains the uploaded file identifier and optional caption. +package struct ConsultantSendMessageItem: Encodable, Equatable { + + /// Uploaded or preinstalled file identifier sent in `items[].id`. + package let id: String + + /// Optional file caption sent in `items[].caption`. + package let caption: String? + + package init( + id: String, + caption: String? = nil + ) { + self.id = id + self.caption = caption + } +} diff --git a/Sources/OnlineConsultant/Models/Responses/ConsultantActionExecuteResponse.swift b/Sources/OnlineConsultant/Models/Responses/ConsultantActionExecuteResponse.swift new file mode 100644 index 0000000..dced42c --- /dev/null +++ b/Sources/OnlineConsultant/Models/Responses/ConsultantActionExecuteResponse.swift @@ -0,0 +1,24 @@ +// +// ConsultantActionExecuteResponse.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Response returned after an automatic site action is marked as executed. +/// +/// The backend response currently contains only `execution_id`, which is later used when the user +/// closes the action, submits an action contact form, or replies to an automatic message. + package struct ConsultantActionExecuteResponse: Decodable, Equatable, Sendable { + + /// Identifier of the created action execution record. + package let executionID: FlexibleID? + + private enum CodingKeys: String, CodingKey { + case executionID = "execution_id" + } + + package init(executionID: FlexibleID?) { + self.executionID = executionID + } +} diff --git a/Sources/OnlineConsultant/Models/Responses/ConsultantConnectResponse.swift b/Sources/OnlineConsultant/Models/Responses/ConsultantConnectResponse.swift new file mode 100644 index 0000000..513873b --- /dev/null +++ b/Sources/OnlineConsultant/Models/Responses/ConsultantConnectResponse.swift @@ -0,0 +1,99 @@ +// +// ConsultantConnectResponse.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Core + +/// The bootstrap response returned by `POST /api/client/v1/connect`. +/// +/// The backend uses this response to return the current user, chat session, consultant configuration, +/// initial messages, and site actions required to start the consultant UI and realtime subscription. +package struct ConsultantConnectResponse: Decodable, Equatable, Sendable { + + /// The backend server timestamp encoded as the `ts` ISO 8601 string. + package let timestamp: String? + + /// The connected consultant user. + /// + /// The user token from this object is used for authorized requests and WebSocket subscription. + package let user: ConnectedUser + + /// The connected chat session. + /// + /// The session id from this object is used for authorized requests and WebSocket subscription. + package let session: ConnectedSession + + /// Feature flags and runtime availability returned by the backend. + package let behaviour: ConnectBehaviour? + + /// Runtime consultant configuration returned by the backend. + package let config: ConsultantConfig? + + /// Initial message history returned by the backend. + /// + /// Missing `messages` is decoded as an empty array. + package let messages: [ConsultantMessage] + + /// Site actions available for the current page and session. + /// + /// Missing `actions` is decoded as an empty array. + package let actions: [SiteAction] + + private enum CodingKeys: String, CodingKey { + case timestamp = "ts" + case user + case session + case behaviour + case config + case messages + case actions + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.timestamp = try container.decodeIfPresent(String.self, forKey: .timestamp) + self.user = try container.decode(ConnectedUser.self, forKey: .user) + self.session = try container.decode(ConnectedSession.self, forKey: .session) + self.behaviour = try container.decodeIfPresent(ConnectBehaviour.self, forKey: .behaviour) + self.config = try container.decodeIfPresent(ConsultantConfig.self, forKey: .config) + self.messages = try container.decodeIfPresent([ConsultantMessage].self, forKey: .messages) ?? [] + self.actions = try container.decodeIfPresent([SiteAction].self, forKey: .actions) ?? [] + } + + package init( + timestamp: String?, + user: ConnectedUser, + session: ConnectedSession, + behaviour: ConnectBehaviour?, + config: ConsultantConfig?, + messages: [ConsultantMessage], + actions: [SiteAction] = [] + ) { + self.timestamp = timestamp + self.user = user + self.session = session + self.behaviour = behaviour + self.config = config + self.messages = messages + self.actions = actions + } +} + +package extension ConsultantConnectResponse { + + /// Returns a copy of the response with submitted contact form data applied to the connected user. + func applyingContactFormSubmitData(_ data: ContactFormSubmitData) -> ConsultantConnectResponse { + ConsultantConnectResponse( + timestamp: timestamp, + user: user.applyingContactFormSubmitData(data), + session: session, + behaviour: behaviour, + config: config, + messages: messages, + actions: actions + ) + } +} diff --git a/Sources/OnlineConsultant/Models/Responses/ConsultantFileLinkResponse.swift b/Sources/OnlineConsultant/Models/Responses/ConsultantFileLinkResponse.swift new file mode 100644 index 0000000..012f49e --- /dev/null +++ b/Sources/OnlineConsultant/Models/Responses/ConsultantFileLinkResponse.swift @@ -0,0 +1,21 @@ +// +// ConsultantFileLinkResponse.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 20.07.2026. +// + +/// Backend response for `GET /api/client/v1/files/:file_id`. +/// +/// The backend returns the requested file identifier and a temporary file URL. The SDK decodes only +/// the URL because the file identifier is already known from the request and is not used after the +/// link is resolved. +package struct ConsultantFileLinkResponse: Decodable, Equatable { + + /// Temporary URL that can be used to download or preview the requested file. + package let url: String? + + package init(url: String?) { + self.url = url + } +} diff --git a/Sources/OnlineConsultant/Models/Responses/ConsultantFileUploadResponse.swift b/Sources/OnlineConsultant/Models/Responses/ConsultantFileUploadResponse.swift new file mode 100644 index 0000000..bb62001 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Responses/ConsultantFileUploadResponse.swift @@ -0,0 +1,13 @@ +/// Response returned after uploading a file to the consultant backend. +/// +/// The backend returns only the uploaded Transport file identifier. A download URL is resolved +/// separately through the file link endpoint. + package struct ConsultantFileUploadResponse: Decodable, Equatable, Sendable { + + /// Uploaded file identifier used later as `items[].id` when sending file or image messages. + package let id: FlexibleID? + + package init(id: FlexibleID?) { + self.id = id + } +} diff --git a/Sources/OnlineConsultant/Models/Responses/ConsultantMessagesResponse.swift b/Sources/OnlineConsultant/Models/Responses/ConsultantMessagesResponse.swift new file mode 100644 index 0000000..70456d3 --- /dev/null +++ b/Sources/OnlineConsultant/Models/Responses/ConsultantMessagesResponse.swift @@ -0,0 +1,22 @@ +// +// ConsultantMessagesResponse.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 20.07.2026. +// + +/// Backend response for `GET /api/client/v1/messages`. +package struct ConsultantMessagesResponse: Decodable, Equatable { + + /// Messages returned by the backend in the `messages` field. + package let messages: [ConsultantMessage] + + private enum CodingKeys: String, CodingKey { + case messages + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + messages = try container.decodeIfPresent([ConsultantMessage].self, forKey: .messages) ?? [] + } +} diff --git a/Sources/OnlineConsultant/Models/Responses/ConsultantSendMessageResponse.swift b/Sources/OnlineConsultant/Models/Responses/ConsultantSendMessageResponse.swift new file mode 100644 index 0000000..3e2df9e --- /dev/null +++ b/Sources/OnlineConsultant/Models/Responses/ConsultantSendMessageResponse.swift @@ -0,0 +1,53 @@ +// +// ConsultantSendMessageResponse.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 21.07.2026. +// + +/// Backend response for `POST /api/client/v1/messages`. +package struct ConsultantSendMessageResponse: Decodable, Equatable, Sendable { + + /// Metadata for the message created or failed by the backend. + package let message: ConsultantSentMessage + + package init(message: ConsultantSentMessage) { + self.message = message + } +} + +/// Backend acknowledgement for a sent message. + package struct ConsultantSentMessage: Decodable, Equatable, Sendable { + + /// Backend message identifier returned in the `message.id` field. + package let id: String + + /// Backend creation time returned in the `message.time` field. + package let time: String + + /// Backend update time returned in the `message.updated_at` field. + package let updatedAt: String? + + /// Backend delivery status returned in the `message.status` field. + package let status: MessageStatus + + private enum CodingKeys: String, CodingKey { + + case id + case time + case updatedAt = "updated_at" + case status + } + + package init( + id: String, + time: String, + updatedAt: String?, + status: MessageStatus + ) { + self.id = id + self.time = time + self.updatedAt = updatedAt + self.status = status + } +} diff --git a/Sources/OnlineConsultant/Resources/ActionImages/confetti.png b/Sources/OnlineConsultant/Resources/ActionImages/confetti.png new file mode 100644 index 0000000000000000000000000000000000000000..8c0179e69eb7a4d0d0484e0a93cb4b22a3a29c88 GIT binary patch literal 31247 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 Bl&Amz literal 0 HcmV?d00001 diff --git a/Sources/OnlineConsultant/Resources/ActionImages/fire.png b/Sources/OnlineConsultant/Resources/ActionImages/fire.png new file mode 100644 index 0000000000000000000000000000000000000000..a100636872beb5bcb53e035ee8eb0d733288012c GIT binary patch literal 18668 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

B7rbu+>wwtQ2Y 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 literal 0 HcmV?d00001 diff --git a/Sources/OnlineConsultant/Resources/ActionImages/present.png b/Sources/OnlineConsultant/Resources/ActionImages/present.png new file mode 100644 index 0000000000000000000000000000000000000000..bc263f2b3f0b0d99a03614e5cb28f5ed27d8052a GIT binary patch literal 24302 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

M*HWT#we&otJm0>&ktoUoNUuuv0{X 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 + + diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/Contents.json b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/Contents.json new file mode 100644 index 0000000..cd580cb --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "🎨 Icon color.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git "a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/\360\237\216\250 Icon color.svg" "b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/\360\237\216\250 Icon color.svg" new file mode 100644 index 0000000..abd494d --- /dev/null +++ "b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_bot_icon.imageset/\360\237\216\250 Icon color.svg" @@ -0,0 +1,3 @@ + + + diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/Contents.json b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/Contents.json new file mode 100644 index 0000000..ed5bb9e --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "close.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/close.pdf b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/close.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f7dd963f3d485caa1b5d9d9df78df4294a150dc1 GIT binary patch literal 4494 zcmeHKO>f*b5WVwP@Dd;?5UogxA_)Wr5`Up+gCy$$4G{FOkrl`3dgZRfZD zZ2RtR+q6BI;F+&pb@%dvEQ>NeX`sRkrO_{-f@VK#+trO&oV34RH|_HLl{~)4-55R7q4tte=7YDYl)gZ+ z6*8s@WtDSoMjQ}Ry5Q@i*NBRUwGYx3y7qNl$+f79I%uosc`!tnue`(N8Tl0%SEYt- zj%e$36p>%hYlJJX=UnvFm>L@qT!@zOL207!;2??#sf(cNV94~OR~OEe4$d^f7Allg zZ6%y`a3_F^<{4uxEzVmT2qU&&F;5j*2NNhL5vj2CgR7M`kw5Aoa4G~1A=%aL^m*VbfCljWOdqV_n3MC2}b8tIA>E zgYyt(h^tf`Mc^kr+Ip1cI1B!J2-kut0#<8L1!2Hhn6h-CRyZ5F;w|f(rRq z@s)NJO^7hU^tginnf@;y1lS1ip*=JX;z95bWa-X95ayDU9|RThSG$tFJ#CE+__+8) zV}%4Y;sA&*fN9dyY;-`%G&l(M4T6!ub z-LyN$-Cfm&H#cfbwz;SNCN?ndm82GEnfi>=`{DdzSPgBIpQ`wG@p{$&8b@P`M6Wvy zP3n7gM~ibwU)Iz)?aKDv7HFAXu5UlDzD+&1MSC0sOnn@$ct!Y%T-nWwJ<3 zy&nqZ7@-;u;M;u=CyLwM z8}ta})Tho>jD`CmCTIjM8dLAgAP!Up4Z5?+<80$WlCeU9vg*PVR1uWhvx07q!Q`PS z!q|7SI$L2RGmlEdxfBWor3At}?K z;b4atTxVcfLmC}WNl+GxdB{VAIXOtDH96UI&=L~|q=qsDH&cX3QoDmgf; zkUY%XPOhY*T#j@)VUkYE$*W%*(kCtMA;P%66JH+TzDj4zP$?2Q3KLLMZ Bnh^j1 literal 0 HcmV?d00001 diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Contents.json b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Contents.json new file mode 100644 index 0000000..f9a7b31 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 674.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 674@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 674@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674.png b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674.png new file mode 100644 index 0000000000000000000000000000000000000000..11866058fde1f4f3fc19a26b5c2a4faa9b260a31 GIT binary patch literal 4251 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 literal 0 HcmV?d00001 diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@2x.png b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3af4933f532c4dbb4678b2b22346136b0d1c92bb GIT binary patch literal 11659 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 B{B-~T literal 0 HcmV?d00001 diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/Contents.json b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/Contents.json new file mode 100644 index 0000000..eb22f97 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "🎨 Icon color.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/\360\237\216\250 Icon color.svg" "b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/\360\237\216\250 Icon color.svg" new file mode 100644 index 0000000..cf94a72 --- /dev/null +++ "b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_info_icon.imageset/\360\237\216\250 Icon color.svg" @@ -0,0 +1,3 @@ + + + diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/Contents.json b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/Contents.json new file mode 100644 index 0000000..a88d70f --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "send.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/send.svg b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/send.svg new file mode 100644 index 0000000..24f7ed4 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_send_icon.imageset/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/OnlineConsultant/Resources/en.lproj/Localizable.strings b/Sources/OnlineConsultant/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..9744b62 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/en.lproj/Localizable.strings @@ -0,0 +1,79 @@ +"attachment.action.camera" = "Camera"; +"attachment.action.cancel" = "Cancel"; +"attachment.action.photo_or_video" = "Photo or video"; +"attachment.file_extension.fallback" = "FILE"; +"attachment.error.camera_missing_usage_description" = "Camera access is not configured in the app."; +"attachment.error.camera_unavailable" = "Camera is unavailable on this device."; +"attachment.error.empty_image_data" = "Could not read the selected photo."; +"attachment.error.file_too_large" = "File size must not exceed 5 MB."; +"attachment.error.image_too_large" = "Photo size must not exceed 3 MB."; +"attachment.error.operation_failed" = "Attachment operation failed: %@"; +"attachment.error.retry_upload_failed" = "Could not retry uploading this file."; +"attachment.error.too_many_attachments" = "You can attach no more than 10 files at once."; +"attachment.status.loading" = "Loading"; +"message.action.copy" = "Copy"; +"message.action.retry" = "Retry sending"; +"common.error" = "Error"; +"common.file" = "File"; +"common.off" = "off"; +"common.ok" = "OK"; +"common.on" = "on"; +"common.retry" = "Retry"; +"common.unavailable" = "Unavailable"; +"contact_form.default_text" = "Please fill out the form to start the conversation."; +"contact_form.field.comment" = "Comment"; +"contact_form.field.email" = "Email"; +"contact_form.field.name" = "Name"; +"contact_form.field.phone" = "Phone"; +"contact_form.marketing_consent" = "I agree to receive marketing messages"; +"contact_form.personal_data_consent" = "I agree to the processing of personal data"; +"contact_form.personal_data_link_text" = "processing of personal data"; +"contact_form.submit" = "Send"; +"contact_form.submitted" = "Sent"; +"controller.error.input_attachment_removed" = "The attachment was removed."; +"controller.error.input_attachments_not_uploaded" = "Attachments must be uploaded before sending."; +"controller.error.missing_credentials" = "Connect response does not contain user token or session id."; +"controller.error.missing_uploaded_file_id" = "File upload response does not contain file id."; +"controller.error.not_connected" = "Consultant session is not connected."; +"controller.error.unavailable" = "The consultant is unavailable"; +"footer.made_in" = "Made in"; +"message.agreement.first" = "By sending a message, you give "; +"message.agreement.link" = "consent to processing"; +"message.agreement.third" = " of personal data"; +"message.author.bot_assistant" = "Bot assistant"; +"message.author.consultant" = "Consultant"; +"message.author.consultant_initial" = "C"; +"message.author.visitor" = "You"; +"message.input.placeholder" = "Message"; +"message.item.image" = "Image"; +"message.item.product" = "Product"; +"offline.default_text" = "Unfortunately, we are not here now, but you can submit your application."; +"offline.form_default_result_text" = "Perfect! We will process your application and contact you!"; +"order.field.address" = "Address"; +"order.field.comment" = "Comment"; +"order.field.delivery" = "Delivery"; +"order.field.delivery_cost" = "Delivery cost"; +"order.field.discount" = "Discount"; +"order.field.payment" = "Payment"; +"order.field.total" = "Total"; +"order.label.number" = "Order %@"; +"product.quantity.available" = "In stock"; +"product.quantity.unavailable" = "Out of stock"; +"client.error.http_status" = "Consultant request failed with HTTP status %d."; +"client.error.missing_credentials" = "Connect response does not contain user token or session ID."; +"client.error.request_failed" = "Consultant request failed: %@"; +"file_cache.error.empty_file_id" = "File ID must not be empty."; +"file_cache.error.missing_entry" = "The file cache does not contain an entry for file ID %@."; +"file_cache.error.operation_failed" = "File cache operation failed: %@"; +"consultant.error.unexpected" = "Consultant operation failed: %@"; + +"message.quote.author_you" = "You"; +"message.quote.photo" = "Photo"; + +"message.quote.placeholder" = "Quoted message"; +"message.quote.unavailable" = "The quoted message is unavailable"; + +"message.date.today" = "Today"; +"message.date.yesterday" = "Yesterday"; +"message.date.format" = "d MMMM"; +"message.date.year_format" = "d MMMM yyyy"; diff --git a/Sources/OnlineConsultant/Resources/es.lproj/Localizable.strings b/Sources/OnlineConsultant/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..e175e97 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/es.lproj/Localizable.strings @@ -0,0 +1,79 @@ +"attachment.action.camera" = "Cámara"; +"attachment.action.cancel" = "Cancelar"; +"attachment.action.photo_or_video" = "Foto o video"; +"attachment.file_extension.fallback" = "ARCHIVO"; +"attachment.error.camera_missing_usage_description" = "El acceso a la cámara no está configurado en la app."; +"attachment.error.camera_unavailable" = "La cámara no está disponible en este dispositivo."; +"attachment.error.empty_image_data" = "No se pudieron leer los datos de la foto seleccionada."; +"attachment.error.file_too_large" = "El archivo no debe superar los 5 MB."; +"attachment.error.image_too_large" = "La foto no debe superar los 3 MB."; +"attachment.error.operation_failed" = "La operación con el archivo adjunto falló: %@"; +"attachment.error.retry_upload_failed" = "No se pudo volver a cargar este archivo."; +"attachment.error.too_many_attachments" = "No puedes adjuntar más de 10 archivos a la vez."; +"attachment.status.loading" = "Cargando"; +"message.action.copy" = "Copiar"; +"message.action.retry" = "Reintentar envío"; +"common.error" = "Error"; +"common.file" = "Archivo"; +"common.off" = "desactivado"; +"common.ok" = "OK"; +"common.on" = "activado"; +"common.retry" = "Reintentar"; +"common.unavailable" = "No disponible"; +"contact_form.default_text" = "Completa el formulario para iniciar la conversación."; +"contact_form.field.comment" = "Comentario"; +"contact_form.field.email" = "Email"; +"contact_form.field.name" = "Nombre"; +"contact_form.field.phone" = "Teléfono"; +"contact_form.marketing_consent" = "Acepto recibir comunicaciones de marketing"; +"contact_form.personal_data_consent" = "Acepto el tratamiento de datos personales"; +"contact_form.personal_data_link_text" = "tratamiento de datos personales"; +"contact_form.submit" = "Enviar"; +"contact_form.submitted" = "Enviado"; +"controller.error.input_attachment_removed" = "El adjunto fue eliminado."; +"controller.error.input_attachments_not_uploaded" = "Los adjuntos deben cargarse antes de enviarlos."; +"controller.error.missing_credentials" = "La respuesta de conexión no contiene el token de usuario o el id de sesión."; +"controller.error.missing_uploaded_file_id" = "La respuesta de carga del archivo no contiene el id del archivo."; +"controller.error.not_connected" = "La sesión del consultor no está conectada."; +"controller.error.unavailable" = "El consultor no está disponible."; +"footer.made_in" = "Hecho en"; +"message.agreement.first" = "Al enviar un mensaje, das tu "; +"message.agreement.link" = "consentimiento para el tratamiento"; +"message.agreement.third" = " de datos personales"; +"message.author.bot_assistant" = "Asistente bot"; +"message.author.consultant" = "Consultor"; +"message.author.consultant_initial" = "C"; +"message.author.visitor" = "Tú"; +"message.input.placeholder" = "Mensaje"; +"message.item.image" = "Imagen"; +"message.item.product" = "Producto"; +"offline.default_text" = "Lo sentimos, ahora no hay sitio. Puede dejar su solicitud."; +"offline.form_default_result_text" = "¡Perfecto! Procesaremos la solicitud y contactaremos con usted."; +"order.field.address" = "Dirección"; +"order.field.comment" = "Comentario"; +"order.field.delivery" = "Entrega"; +"order.field.delivery_cost" = "Coste de entrega"; +"order.field.discount" = "Descuento"; +"order.field.payment" = "Pago"; +"order.field.total" = "Total"; +"order.label.number" = "Pedido %@"; +"product.quantity.available" = "Disponible"; +"product.quantity.unavailable" = "Agotado"; +"client.error.http_status" = "La solicitud del consultor falló con el estado HTTP %d."; +"client.error.missing_credentials" = "La respuesta de conexión no contiene el token de usuario o el ID de sesión."; +"client.error.request_failed" = "La solicitud del consultor falló: %@"; +"file_cache.error.empty_file_id" = "El ID del archivo no puede estar vacío."; +"file_cache.error.missing_entry" = "La caché de archivos no contiene una entrada para el ID de archivo %@."; +"file_cache.error.operation_failed" = "La operación de caché de archivos falló: %@"; +"consultant.error.unexpected" = "La operación del consultor falló: %@"; + +"message.quote.author_you" = "Tú"; +"message.quote.photo" = "Foto"; + +"message.quote.placeholder" = "Mensaje citado"; +"message.quote.unavailable" = "El mensaje citado no está disponible"; + +"message.date.today" = "Hoy"; +"message.date.yesterday" = "Ayer"; +"message.date.format" = "d 'de' MMMM"; +"message.date.year_format" = "d 'de' MMMM 'de' yyyy"; diff --git a/Sources/OnlineConsultant/Resources/ru.lproj/Localizable.strings b/Sources/OnlineConsultant/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000..05c8280 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,79 @@ +"attachment.action.camera" = "Камера"; +"attachment.action.cancel" = "Отмена"; +"attachment.action.photo_or_video" = "Фото или видео"; +"attachment.file_extension.fallback" = "ФАЙЛ"; +"attachment.error.camera_missing_usage_description" = "В приложении не настроен доступ к камере."; +"attachment.error.camera_unavailable" = "Камера недоступна на этом устройстве."; +"attachment.error.empty_image_data" = "Не удалось получить данные выбранного фото."; +"attachment.error.file_too_large" = "Размер файла не должен превышать 5 МБ."; +"attachment.error.image_too_large" = "Размер фото не должен превышать 3 МБ."; +"attachment.error.operation_failed" = "Операция с вложением завершилась ошибкой: %@"; +"attachment.error.retry_upload_failed" = "Не удалось повторить загрузку этого файла."; +"attachment.error.too_many_attachments" = "Можно прикрепить не больше 10 файлов за раз."; +"attachment.status.loading" = "Загрузка"; +"message.action.copy" = "Скопировать"; +"message.action.retry" = "Повторить отправку"; +"common.error" = "Ошибка"; +"common.file" = "Файл"; +"common.off" = "выключено"; +"common.ok" = "OK"; +"common.on" = "включено"; +"common.retry" = "Повторить"; +"common.unavailable" = "Недоступно"; +"contact_form.default_text" = "Пожалуйста, заполните форму, чтобы начать диалог."; +"contact_form.field.comment" = "Комментарий"; +"contact_form.field.email" = "Email"; +"contact_form.field.name" = "Имя"; +"contact_form.field.phone" = "Телефон"; +"contact_form.marketing_consent" = "Соглашаюсь на получение маркетинговой рассылки"; +"contact_form.personal_data_consent" = "Соглашаюсь на обработку персональных данных"; +"contact_form.personal_data_link_text" = "обработку персональных данных"; +"contact_form.submit" = "Отправить"; +"contact_form.submitted" = "Отправлено"; +"controller.error.input_attachment_removed" = "Вложение было удалено."; +"controller.error.input_attachments_not_uploaded" = "Вложения нужно загрузить перед отправкой."; +"controller.error.missing_credentials" = "Ответ подключения не содержит токен пользователя или id сессии."; +"controller.error.missing_uploaded_file_id" = "Ответ загрузки файла не содержит id файла."; +"controller.error.not_connected" = "Сессия консультанта не подключена."; +"controller.error.unavailable" = "Консультант недоступен"; +"footer.made_in" = "Сделано в"; +"message.agreement.first" = "Отправляя сообщение, вы даёте "; +"message.agreement.link" = "согласие на обработку"; +"message.agreement.third" = " персональных данных"; +"message.author.bot_assistant" = "Бот-помощник"; +"message.author.consultant" = "Консультант"; +"message.author.consultant_initial" = "К"; +"message.author.visitor" = "Вы"; +"message.input.placeholder" = "Сообщение"; +"message.item.image" = "Изображение"; +"message.item.product" = "Товар"; +"offline.default_text" = "К сожалению, сейчас нас нет на месте. Но вы можете оставить вашу заявку."; +"offline.form_default_result_text" = "Отлично, мы обработаем заявку и свяжемся с вами!"; +"order.field.address" = "Адрес"; +"order.field.comment" = "Комментарий"; +"order.field.delivery" = "Доставка"; +"order.field.delivery_cost" = "Стоимость доставки"; +"order.field.discount" = "Скидка"; +"order.field.payment" = "Оплата"; +"order.field.total" = "Итого"; +"order.label.number" = "Заказ %@"; +"product.quantity.available" = "В наличии"; +"product.quantity.unavailable" = "Нет в наличии"; +"client.error.http_status" = "Запрос консультанта завершился с HTTP-статусом %d."; +"client.error.missing_credentials" = "Ответ подключения не содержит токен пользователя или ID сессии."; +"client.error.request_failed" = "Запрос консультанта завершился ошибкой: %@"; +"file_cache.error.empty_file_id" = "ID файла не должен быть пустым."; +"file_cache.error.missing_entry" = "В кеше отсутствует запись для файла с ID %@."; +"file_cache.error.operation_failed" = "Операция с кешем файлов завершилась ошибкой: %@"; +"consultant.error.unexpected" = "Операция консультанта завершилась ошибкой: %@"; + +"message.quote.author_you" = "Вы"; +"message.quote.photo" = "Фото"; + +"message.quote.placeholder" = "Цитируемое сообщение"; +"message.quote.unavailable" = "Цитируемое сообщение недоступно"; + +"message.date.today" = "Сегодня"; +"message.date.yesterday" = "Вчера"; +"message.date.format" = "d MMMM"; +"message.date.year_format" = "d MMMM yyyy'г'"; diff --git a/Sources/OnlineConsultant/SDKResourceBundle.swift b/Sources/OnlineConsultant/SDKResourceBundle.swift new file mode 100644 index 0000000..3a0356f --- /dev/null +++ b/Sources/OnlineConsultant/SDKResourceBundle.swift @@ -0,0 +1,18 @@ +#if !SWIFT_PACKAGE +import Foundation + +private final class SDKResourceBundleToken {} + +extension Bundle { + static let module: Bundle = { + let moduleBundle = Bundle(for: SDKResourceBundleToken.self) + for container in [moduleBundle, Bundle.main] { + if let url = container.url(forResource: "SimlaOnlineConsultant", withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } + } + fatalError("Missing SimlaOnlineConsultant.bundle") + }() +} +#endif diff --git a/Sources/OnlineConsultant/SwiftUI/OnlineConsultantObserver.swift b/Sources/OnlineConsultant/SwiftUI/OnlineConsultantObserver.swift new file mode 100644 index 0000000..3e9e87a --- /dev/null +++ b/Sources/OnlineConsultant/SwiftUI/OnlineConsultantObserver.swift @@ -0,0 +1,145 @@ +import Combine +import Core +import SwiftUI + +/// Observable adapter that publishes state from an `OnlineConsultantController` to SwiftUI. +@MainActor +public final class OnlineConsultantObserver: ObservableObject { + + /// Chat controller observed by this adapter. + public let onlineConsultant: OnlineConsultantController + + /// Current connection state. + @Published public private(set) var state: ControllerState + /// Number of unread incoming messages in the loaded history; zero when the counter is disabled. + @Published public private(set) var unreadIncomingMessagesCount: Int + /// Chat title received from backend `config.name`. + @Published public private(set) var name: String? + /// External consultant link settings received from the backend. + @Published public private(set) var linkSettings: LinkSettings + /// Local file-cache statistics from the last refresh or cache operation. + @Published public private(set) var cacheStatistics: ConsultantCacheStatistics + + private var observationTasks: [Task] = [] + + /// Creates an observable adapter around an existing online consultant. + /// + /// - Parameters: + /// - onlineConsultant: Chat controller whose state the observer publishes. + public init(onlineConsultant: OnlineConsultantController) { + self.onlineConsultant = onlineConsultant + self.state = onlineConsultant.state + self.unreadIncomingMessagesCount = onlineConsultant.unreadIncomingMessagesCount + self.name = onlineConsultant.name + self.linkSettings = onlineConsultant.linkSettings + self.cacheStatistics = onlineConsultant.cacheStatistics() + startObservingController() + } + + deinit { + observationTasks.forEach { $0.cancel() } + } +} + +// MARK: - Controller API + +public extension OnlineConsultantObserver { + + /// Refreshes published local file-cache statistics. + func refreshCacheStatistics() { + cacheStatistics = onlineConsultant.cacheStatistics() + } + + /// Applies the configured cache policy and publishes the updated statistics. + func trimCache() async throws -> ConsultantCacheStatistics { + let statistics = try await onlineConsultant.trimCache() + cacheStatistics = statistics + return statistics + } + + /// Clears the local file cache and refreshes published statistics. + func clearCache() async throws { + try await onlineConsultant.clearCache() + refreshCacheStatistics() + } + + /// Forwards the controller's handler; assignment immediately reports its current state. + var onStateChange: ((ControllerState) -> Void)? { + get { onlineConsultant.onStateChange } + set { onlineConsultant.onStateChange = newValue } + } + + /// Forwards the controller's handler; assignment immediately reports its current unread count. + var onUnreadIncomingMessagesCountChange: ((Int) -> Void)? { + get { onlineConsultant.onUnreadIncomingMessagesCountChange } + set { onlineConsultant.onUnreadIncomingMessagesCountChange = newValue } + } +} + +/// Completion-based file-cache API. +public extension OnlineConsultantObserver { + + /// Applies the configured cache policy and returns statistics in the completion. + /// + /// - Parameter completion: Receives the updated cache statistics or a cache error. + func trimCache( + completion: (Result) -> Void + ) { + onlineConsultant.trimCache { result in + if case .success(let statistics) = result { + cacheStatistics = statistics + } + completion(result) + } + } + + /// Clears all locally cached files managed by the SDK. + /// + /// - Parameter completion: Receives the cache cleanup result. + func clearCache(completion: (Result) -> Void) { + onlineConsultant.clearCache { result in + if case .success = result { + refreshCacheStatistics() + } + completion(result) + } + } +} + +// MARK: - Observation + +private extension OnlineConsultantObserver { + + func startObservingController() { + let onlineConsultant = onlineConsultant + let events = onlineConsultant.events() + observationTasks = [ + Task { [weak self] in + for await state in onlineConsultant.stateUpdates() { + guard !Task.isCancelled else { return } + self?.state = state + self?.refreshConfigurationState() + } + }, + Task { [weak self] in + for await event in events { + guard !Task.isCancelled else { return } + if case .unreadIncomingMessagesCountChanged(let count) = event { + self?.unreadIncomingMessagesCount = count + } + } + }, + Task { [weak self] in + for await _ in onlineConsultant.internalStateUpdates() { + guard !Task.isCancelled else { return } + self?.refreshConfigurationState() + } + } + ] + } + + func refreshConfigurationState() { + name = onlineConsultant.name + linkSettings = onlineConsultant.linkSettings + } +} diff --git a/Sources/OnlineConsultant/SwiftUI/OnlineConsultantScreen.swift b/Sources/OnlineConsultant/SwiftUI/OnlineConsultantScreen.swift new file mode 100644 index 0000000..b1a1d2f --- /dev/null +++ b/Sources/OnlineConsultant/SwiftUI/OnlineConsultantScreen.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// SwiftUI view that embeds the SDK's shared chat view controller. +@MainActor +public struct OnlineConsultantScreen: UIViewControllerRepresentable { + + /// Creates a chat screen backed by `Consultant.shared.onlineConsultant`. + public init() {} + + /// Creates the UIKit consultant chat screen for SwiftUI. + /// + /// - Parameters: + /// - context: SwiftUI context for the UIKit chat screen. + public func makeUIViewController(context: Context) -> OnlineConsultantViewController { + OnlineConsultantViewController() + } + + /// Retains the shared UIKit chat screen when SwiftUI refreshes the wrapper. + /// + /// - Parameters: + /// - uiViewController: UIKit chat screen managed by SwiftUI. + /// - context: SwiftUI context for the UIKit chat screen. + public func updateUIViewController( + _ uiViewController: OnlineConsultantViewController, + context: Context + ) {} +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/ContactFormMessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/ContactFormMessageBubbleView.swift new file mode 100644 index 0000000..53be5a5 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/ContactFormMessageBubbleView.swift @@ -0,0 +1,338 @@ +import UIKit + +/// Renders a contact form message with optional title, optional image, and form fields. +package final class ContactFormMessageBubbleView: UIView { + + private lazy var bubbleView: MessageBubbleView = { + let view = MessageBubbleView( + contentInsets: UIEdgeInsets( + top: MessageLayout.bubbleVerticalInset, + left: MessageLayout.bubbleHorizontalInset, + bottom: MessageLayout.bubbleVerticalInset, + right: MessageLayout.bubbleHorizontalInset + ) + ) + view.accessibilityIdentifier = "contactFormMessageBubbleView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.spacing = MessageLayout.contentItemSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.accessibilityIdentifier = "contactFormTitleLabel" + label.font = MessageBubbleStyle.semiboldMediumFont + label.numberOfLines = 0 + label.isHidden = true + return label + }() + + private lazy var imageContainerView: UIView = { + let view = UIView() + view.accessibilityIdentifier = "contactFormImageContainerView" + view.backgroundColor = MessageBubbleStyle.placeholderBackgroundColor + view.layer.cornerRadius = MessageLayout.contentCornerRadius + view.clipsToBounds = true + view.isHidden = true + return view + }() + + private lazy var imageView: UIImageView = { + let view = UIImageView() + view.accessibilityIdentifier = "contactFormImageView" + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var placeholderImageView: UIImageView = { + let view = UIImageView() + view.accessibilityIdentifier = "contactFormImagePlaceholderView" + view.image = ConsultantImageResource.photo.image + view.tintColor = MessageBubbleStyle.placeholderIconColor + view.contentMode = .scaleAspectFit + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var formContentView: ContactFormContentView = { + let view = ContactFormContentView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var imageHeightConstraint: NSLayoutConstraint? + private var imageLoadTask: ImageLoadTask? + private var currentImageURL: URL? + private var currentImageSize: CGSize? + + /// Called when the embedded contact form passes validation and is submitted. + package var onSubmit: ((ContactFormSubmitData) -> Void)? { + didSet { + formContentView.onSubmit = onSubmit + } + } + + package var onPersonalDataLinkTap: ((URL) -> Void)? { + didSet { + formContentView.onPersonalDataLinkTap = onPersonalDataLinkTap + } + } + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + package override func layoutSubviews() { + super.layoutSubviews() + updateImageHeight() + } + + /// Cancels image loading and clears form, title, image, submit callback, and loading state before reuse. + package func prepareForReuse() { + imageLoadTask?.cancel() + imageLoadTask = nil + currentImageURL = nil + currentImageSize = nil + imageView.image = nil + placeholderImageView.isHidden = false + titleLabel.text = nil + titleLabel.isHidden = true + imageContainerView.isHidden = true + formContentView.prepareForReuse() + onSubmit = nil + onPersonalDataLinkTap = nil + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + addSubview(bubbleView) + + bubbleView.contentView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(titleLabel) + + contentStackView.addArrangedSubview(imageContainerView) + + imageContainerView.addSubview(imageView) + + imageContainerView.addSubview(placeholderImageView) + + contentStackView.addArrangedSubview(formContentView) + } + + private func setupConstraints() { + imageHeightConstraint = imageContainerView.heightAnchor.constraint(equalToConstant: 0) + imageHeightConstraint?.isActive = true + + NSLayoutConstraint.activate([ + bubbleView.topAnchor.constraint(equalTo: topAnchor), + bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor), + bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor), + bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentStackView.topAnchor.constraint(equalTo: bubbleView.contentView.topAnchor), + contentStackView.leadingAnchor.constraint(equalTo: bubbleView.contentView.leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: bubbleView.contentView.trailingAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bubbleView.contentView.bottomAnchor), + + imageView.topAnchor.constraint(equalTo: imageContainerView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: imageContainerView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: imageContainerView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: imageContainerView.bottomAnchor), + + placeholderImageView.centerXAnchor.constraint(equalTo: imageContainerView.centerXAnchor), + placeholderImageView.centerYAnchor.constraint(equalTo: imageContainerView.centerYAnchor), + placeholderImageView.widthAnchor.constraint(equalToConstant: 32), + placeholderImageView.heightAnchor.constraint(equalToConstant: 32) + ]) + } + + /// Updates the embedded form submitting state without reconfiguring its fields. + package func setSubmitting(_ isSubmitting: Bool) { + formContentView.setSubmitting(isSubmitting) + } + + /// Applies contact form content, optional header image/title, bubble shape, theme, and submitting state. + package func configure( + message: ConsultantMessage, + roundsAllCorners: Bool, + isSubmitting: Bool, + theme: ConsultantTheme = .defaultTheme, + personalDataLink: URL? = nil + ) { + bubbleView.configure( + isOutgoing: false, + roundsAllCorners: true, + theme: theme + ) + + let textColor = MessageBubbleStyle.textColor( + isOutgoing: false, + theme: theme + ) + titleLabel.text = message.header?.nilIfEmpty + titleLabel.textColor = textColor + titleLabel.isHidden = titleLabel.text == nil + loadImage( + from: message.contactFormImage?.url, + imageSize: message.contactFormImage?.size + ) + + if let form = message.contactForm { + formContentView.setTheme(theme) + formContentView.setPersonalDataLink(personalDataLink) + formContentView.configure( + form: form, + layoutMode: .messageBubble + ) + formContentView.setSubmitting(isSubmitting) + } + } + + private func loadImage( + from url: URL?, + imageSize: CGSize? + ) { + imageLoadTask?.cancel() + imageLoadTask = nil + currentImageURL = url + currentImageSize = imageSize + imageView.image = nil + placeholderImageView.isHidden = false + imageContainerView.isHidden = url == nil + updateImageHeight() + + guard let url else { + return + } + + imageLoadTask = ImageLoader.shared.loadImage(from: url) { [weak self] image in + guard self?.currentImageURL == url else { + return + } + + self?.imageView.image = image + self?.placeholderImageView.isHidden = image != nil + } + } + + private func updateImageHeight() { + let width = [ + imageContainerView.bounds.width, + contentStackView.bounds.width, + bounds.width - MessageLayout.bubbleHorizontalInset * 2 + ] + .filter { $0 > 1 } + .first ?? 1 + imageHeightConstraint?.constant = Self.imageHeight( + for: currentImageSize, + url: currentImageURL, + width: width + ) + } +} + +package extension ContactFormMessageBubbleView { + + /// Calculates the height needed for the form plus optional title and image content. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth: CGFloat + ) -> CGFloat { + guard let form = message.contactForm else { + return ContactFormMessageLayout.preferredHeight(formHeight: 0) + } + + let maxBubbleWidth = collectionWidth * MessageLayout.bubbleMaxWidthMultiplier + let bubbleWidth = ContactFormMessageLayout.preferredBubbleWidth( + maxBubbleWidth: maxBubbleWidth + ) + let contentWidth = max( + 1, + bubbleWidth - MessageLayout.bubbleHorizontalInset * 2 + ) + let formHeight = ContactFormContentView.preferredHeight( + for: form, + width: contentWidth, + layoutMode: .messageBubble + ) + let titleHeight = Self.titleHeight( + text: message.header?.nilIfEmpty, + width: contentWidth + ) + let imageHeight = Self.imageHeight( + for: message.contactFormImage?.size, + url: message.contactFormImage?.url, + width: contentWidth + ) + let visibleBlocks = 1 + + (titleHeight > 0 ? 1 : 0) + + (imageHeight > 0 ? 1 : 0) + let contentHeight = formHeight + + titleHeight + + imageHeight + + CGFloat(max(0, visibleBlocks - 1)) * MessageLayout.contentItemSpacing + + return ContactFormMessageLayout.preferredHeight(formHeight: contentHeight) + } + + private static func titleHeight( + text: String?, + width: CGFloat + ) -> CGFloat { + guard let text else { + return 0 + } + + return ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, width), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: MessageBubbleStyle.semiboldMediumFont], + context: nil + ).height + ) + } + + private static func imageHeight( + for imageSize: CGSize?, + url: URL?, + width: CGFloat + ) -> CGFloat { + guard url != nil else { + return 0 + } + + guard + let imageSize, + imageSize.width > 0, + imageSize.height > 0 + else { + return MessageLayout.embeddedImageFallbackHeight + } + + return ceil(width * imageSize.height / imageSize.width) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/FileMessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/FileMessageBubbleView.swift new file mode 100644 index 0000000..fbbd852 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/FileMessageBubbleView.swift @@ -0,0 +1,323 @@ +import UIKit + +/// Renders one or more file attachments with optional author and delivery metadata. +package final class FileMessageBubbleView: UIView { + + private lazy var bubbleView: UIView = { + let view = UIView() + view.layer.cornerRadius = MessageLayout.contentCornerRadius + view.clipsToBounds = true + view.accessibilityIdentifier = "fileMessageBubbleView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.compactSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var authorLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.authorFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "fileMessageAuthorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var filesStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.compactSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var metadataView: MetadataView = { + let view = MetadataView() + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentCompressionResistancePriority(.required, for: .vertical) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var fileRowViews: [FileMessageItemView] = [] + private var contentStackTopConstraint: NSLayoutConstraint? + /// Called when the user taps a downloadable file row that can be previewed. + package var onFilePreview: (([FilePreviewItem], Int) -> Void)? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Clears file rows, preview callbacks, author text, and metadata before reuse. + package func prepareForReuse() { + authorLabel.text = nil + authorLabel.isHidden = false + onFilePreview = nil + removeFileRows() + metadataView.prepareForReuse() + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + addSubview(bubbleView) + + bubbleView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(authorLabel) + contentStackView.addArrangedSubview(filesStackView) + contentStackView.addArrangedSubview(metadataView) + } + + private func setupConstraints() { + let contentStackTopConstraint = contentStackView.topAnchor.constraint( + equalTo: bubbleView.topAnchor, + constant: MessageLayout.bubbleVerticalInset + ) + self.contentStackTopConstraint = contentStackTopConstraint + + NSLayoutConstraint.activate([ + bubbleView.topAnchor.constraint(equalTo: topAnchor), + bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor), + bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor), + bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentStackTopConstraint, + contentStackView.leadingAnchor.constraint( + equalTo: bubbleView.leadingAnchor, + constant: MessageLayout.bubbleHorizontalInset + ), + contentStackView.trailingAnchor.constraint( + equalTo: bubbleView.trailingAnchor, + constant: -MessageLayout.bubbleHorizontalInset + ), + contentStackView.bottomAnchor.constraint( + equalTo: bubbleView.bottomAnchor, + constant: -FileMessageLayout.bubbleBottomInset + ), + + metadataView.heightAnchor.constraint( + greaterThanOrEqualToConstant: MessageLayout.secondaryLineHeight + ) + ]) + } + + /// Applies file rows, author visibility, bubble shape, colors, loading state, and metadata. + package func configure( + message: ConsultantMessage, + showsAuthor: Bool = true, + roundsAllCorners: Bool = false, + theme: ConsultantTheme = .defaultTheme, + authorNameOverride: String? = nil, + loadingFilePreviewIDs: Set = [] + ) { + let isOutgoing = message.fromMe == true + + configureBubble( + isOutgoing: isOutgoing, + roundsAllCorners: roundsAllCorners, + theme: theme + ) + configureColors( + isOutgoing: isOutgoing, + theme: theme + ) + + authorLabel.text = showsAuthor + ? authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + : nil + authorLabel.isHidden = !showsAuthor + contentStackTopConstraint?.constant = showsAuthor + ? MessageLayout.bubbleVerticalInset + : FileMessageLayout.bubbleTopInsetWithoutAuthor + configureFileRows( + items: message.fileDisplayItems, + previewItems: message.filePreviewItems, + isOutgoing: isOutgoing, + theme: theme, + loadingFilePreviewIDs: loadingFilePreviewIDs + ) + metadataView.configure( + message: message, + tintColor: MessageBubbleStyle.secondaryColor( + isOutgoing: isOutgoing, + theme: theme + ), + timestampAccessibilityIdentifier: "fileMessageMetadataLabel", + statusAccessibilityIdentifier: "fileMessageStatusImageView" + ) + } + + /// Refreshes only the outgoing delivery status without rebuilding file rows. + package func updateDeliveryStatus( + message: ConsultantMessage, + theme: ConsultantTheme = .defaultTheme + ) { + metadataView.updateDeliveryStatus( + message: message, + tintColor: MessageBubbleStyle.secondaryColor( + isOutgoing: message.fromMe == true, + theme: theme + ), + statusAccessibilityIdentifier: "fileMessageStatusImageView" + ) + } + + private func configureBubble( + isOutgoing: Bool, + roundsAllCorners: Bool, + theme: ConsultantTheme + ) { + bubbleView.backgroundColor = MessageBubbleStyle.bubbleColor( + isOutgoing: isOutgoing, + theme: theme + ) + bubbleView.layer.cornerRadius = theme.roundedElements + ? MessageLayout.contentCornerRadius + : MessageLayout.compactCornerRadius + bubbleView.layer.borderWidth = 0 + bubbleView.layer.borderColor = nil + bubbleView.layer.maskedCorners = roundsAllCorners + ? Self.allCorners + : tailCorners(isOutgoing: isOutgoing) + } + + private func configureColors( + isOutgoing: Bool, + theme: ConsultantTheme + ) { + let mainColor = theme.brandColor + let primaryTextColor = MessageBubbleStyle.textColor( + isOutgoing: isOutgoing, + theme: theme + ) + let sizeTextColor = isOutgoing + ? theme.mainTextColor + : theme.messageDateColor + + authorLabel.textColor = primaryTextColor + fileRowViews.forEach { + $0.configureColors( + isOutgoing: isOutgoing, + primaryTextColor: primaryTextColor, + sizeTextColor: sizeTextColor, + iconBackgroundColor: isOutgoing + ? theme.mainTextColor + : mainColor, + iconTintColor: isOutgoing + ? mainColor + : theme.mainTextColor + ) + } + } + + private func configureFileRows( + items: [MessageFileDisplayItem], + previewItems: [FilePreviewItem], + isOutgoing: Bool, + theme: ConsultantTheme, + loadingFilePreviewIDs: Set + ) { + removeFileRows() + + var nextPreviewIndex = 0 + + for item in items { + let rowView = FileMessageItemView() + let previewIndex: Int? + if item.urlString.flatMap(URL.init(string:)) == nil { + previewIndex = nil + } else { + previewIndex = nextPreviewIndex + nextPreviewIndex += 1 + } + rowView.translatesAutoresizingMaskIntoConstraints = false + rowView.onTap = { [weak self] in + guard let previewIndex else { + return + } + + self?.onFilePreview?(previewItems, previewIndex) + } + rowView.configure( + item: item, + isTapEnabled: previewIndex != nil, + isLoading: item.id.map { loadingFilePreviewIDs.contains($0) } ?? false + ) + filesStackView.addArrangedSubview(rowView) + fileRowViews.append(rowView) + + rowView.heightAnchor.constraint( + equalToConstant: FileMessageLayout.fileBlockHeight + ).isActive = true + } + + configureColors( + isOutgoing: isOutgoing, + theme: theme + ) + } + + private func removeFileRows() { + for rowView in fileRowViews { + rowView.prepareForReuse() + filesStackView.removeArrangedSubview(rowView) + rowView.removeFromSuperview() + } + + fileRowViews.removeAll() + } + + private func tailCorners(isOutgoing: Bool) -> CACornerMask { + isOutgoing + ? [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner] + : [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner] + } + +} + +package extension FileMessageBubbleView { + /// Calculates the height needed for the current number of file rows. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth _: CGFloat, + showsAuthor: Bool + ) -> CGFloat { + FileMessageLayout.preferredHeight( + showsAuthor: showsAuthor, + itemCount: message.fileDisplayItems.count + ) + } + + private static let allCorners: CACornerMask = [ + .layerMinXMinYCorner, + .layerMaxXMinYCorner, + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/ImageMessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/ImageMessageBubbleView.swift new file mode 100644 index 0000000..881e559 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/ImageMessageBubbleView.swift @@ -0,0 +1,244 @@ +import UIKit + +/// Renders one or more image attachments with optional author and metadata overlay. +package final class ImageMessageBubbleView: UIView { + + private lazy var bubbleView: MessageBubbleView = { + let view = MessageBubbleView( + contentInsets: UIEdgeInsets( + top: MessageLayout.compactSpacing, + left: MessageLayout.compactSpacing, + bottom: MessageLayout.compactSpacing, + right: MessageLayout.compactSpacing + ) + ) + view.accessibilityIdentifier = "imageMessageBubbleView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.bubbleContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var authorLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.authorFont + label.textColor = MessageBubbleStyle.textColor + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "imageMessageAuthorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var imagesStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .leading + view.distribution = .fill + view.spacing = MessageLayout.contentItemSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var imageItemViews: [ImageMessageItemView] = [] + /// Called when the user taps an image attachment that can be previewed. + package var onImagePreview: (([ImagePreviewItem], Int) -> Void)? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + package override func layoutSubviews() { + super.layoutSubviews() + updateImageSize() + } + + /// Clears image item views, preview callbacks, and author state before reuse. + package func prepareForReuse() { + authorLabel.text = nil + authorLabel.isHidden = false + onImagePreview = nil + removeImageItemViews() + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + addSubview(bubbleView) + + bubbleView.contentView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(authorLabel) + contentStackView.addArrangedSubview(imagesStackView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + bubbleView.topAnchor.constraint(equalTo: topAnchor), + bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor), + bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor), + bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentStackView.topAnchor.constraint(equalTo: bubbleView.contentView.topAnchor), + contentStackView.leadingAnchor.constraint(equalTo: bubbleView.contentView.leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: bubbleView.contentView.trailingAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bubbleView.contentView.bottomAnchor) + ]) + } + + /// Applies image attachments, author visibility, bubble shape, loading state, and image worker hosts. + package func configure( + message: ConsultantMessage, + showsAuthor: Bool = true, + roundsAllCorners: Bool = false, + theme: ConsultantTheme = .defaultTheme, + authorNameOverride: String? = nil, + imageWorkers: [String] = [], + loadingImagePreviewIDs: Set = [] + ) { + let isOutgoing = message.fromMe == true + let textColor = MessageBubbleStyle.textColor( + isOutgoing: isOutgoing, + theme: theme + ) + + bubbleView.configure( + isOutgoing: isOutgoing, + roundsAllCorners: roundsAllCorners, + theme: theme + ) + bubbleView.setContentInsets( + UIEdgeInsets( + top: showsAuthor + ? MessageLayout.bubbleVerticalInset + : MessageLayout.compactSpacing, + left: MessageLayout.compactSpacing, + bottom: MessageLayout.compactSpacing, + right: MessageLayout.compactSpacing + ) + ) + authorLabel.text = showsAuthor + ? authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + : nil + authorLabel.isHidden = !showsAuthor + authorLabel.textColor = textColor + configureImageItems( + message: message, + imageWorkers: imageWorkers, + loadingImagePreviewIDs: loadingImagePreviewIDs + ) + updateImageSize() + } + + /// Refreshes delivery metadata on the last image item, where the metadata overlay is rendered. + package func updateDeliveryStatus( + message: ConsultantMessage, + theme: ConsultantTheme = .defaultTheme + ) { + imageItemViews.last?.updateDeliveryStatus( + message: message, + statusAccessibilityIdentifier: "imageMessageStatusImageView" + ) + } + + private func updateImageSize() { + let maxBubbleWidth = max(1, bounds.width) + imageItemViews.forEach { $0.updateSize(maxBubbleWidth: maxBubbleWidth) } + } + + private func configureImageItems( + message: ConsultantMessage, + imageWorkers: [String], + loadingImagePreviewIDs: Set + ) { + removeImageItemViews() + + let previewItems = message.imagePreviewItems + var nextPreviewIndex = 0 + + for (index, item) in message.imageDisplayItems.enumerated() { + let itemView = ImageMessageItemView() + let previewIndex: Int? + if item.urlString.flatMap(URL.init(string:)) == nil { + previewIndex = nil + } else { + previewIndex = nextPreviewIndex + nextPreviewIndex += 1 + } + itemView.translatesAutoresizingMaskIntoConstraints = false + itemView.onTap = { [weak self] in + guard let previewIndex else { + return + } + + self?.onImagePreview?(previewItems, previewIndex) + } + itemView.configure( + item: item, + message: message, + showsMetadata: index == message.imageDisplayItems.count - 1, + imageWorkers: imageWorkers, + isTapEnabled: previewIndex != nil, + isLoading: item.id.map { loadingImagePreviewIDs.contains($0) } ?? false + ) + imagesStackView.addArrangedSubview(itemView) + imageItemViews.append(itemView) + } + } + + private func removeImageItemViews() { + for itemView in imageItemViews { + itemView.prepareForReuse() + imagesStackView.removeArrangedSubview(itemView) + itemView.removeFromSuperview() + } + + imageItemViews.removeAll() + } + +} + +package extension ImageMessageBubbleView { + /// Calculates the height needed for all image attachments in the message. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth: CGFloat, + showsAuthor: Bool = true + ) -> CGFloat { + ImageMessageLayout.preferredHeight( + collectionWidth: collectionWidth, + sourceSizes: message.imageDisplayItems.map(\.sourceImageSize), + showsAuthor: showsAuthor + ) + } + + /// Calculates the fallback height for an image bubble without message-specific source sizes. + static func preferredHeight(collectionWidth: CGFloat) -> CGFloat { + ImageMessageLayout.preferredHeight( + collectionWidth: collectionWidth, + showsAuthor: false + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/OrderMessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/OrderMessageBubbleView.swift new file mode 100644 index 0000000..5a7eef7 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/OrderMessageBubbleView.swift @@ -0,0 +1,464 @@ +import UIKit + +/// Renders an order message with order number, items, fields, author, and metadata. +package final class OrderMessageBubbleView: UIView { + + private lazy var bubbleView: MessageBubbleView = { + let view = MessageBubbleView( + contentInsets: UIEdgeInsets( + top: MessageLayout.bubbleVerticalInset, + left: MessageLayout.bubbleHorizontalInset, + bottom: MessageLayout.bubbleVerticalInset, + right: MessageLayout.bubbleHorizontalInset + ) + ) + view.accessibilityIdentifier = "orderMessageBubbleView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.bubbleContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var authorLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.authorFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "orderMessageAuthorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var orderNumberLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.semiboldMediumFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "orderMessageNumberLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var orderContentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.bubbleContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var metadataView: MetadataView = { + let view = MetadataView() + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentCompressionResistancePriority(.required, for: .vertical) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Clears order content, author, order number, and metadata before reuse. + package func prepareForReuse() { + authorLabel.text = nil + authorLabel.isHidden = false + orderNumberLabel.text = nil + orderNumberLabel.isHidden = false + clearOrderContent() + metadataView.prepareForReuse() + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + addSubview(bubbleView) + + bubbleView.contentView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(authorLabel) + contentStackView.addArrangedSubview(orderNumberLabel) + contentStackView.addArrangedSubview(orderContentStackView) + contentStackView.addArrangedSubview(metadataView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + bubbleView.topAnchor.constraint(equalTo: topAnchor), + bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor), + bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor), + bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentStackView.topAnchor.constraint(equalTo: bubbleView.contentView.topAnchor), + contentStackView.leadingAnchor.constraint(equalTo: bubbleView.contentView.leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: bubbleView.contentView.trailingAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bubbleView.contentView.bottomAnchor), + + metadataView.heightAnchor.constraint( + greaterThanOrEqualToConstant: MessageLayout.secondaryLineHeight + ) + ]) + } + + /// Applies order display fields, linked items, author visibility, bubble shape, colors, and metadata. + package func configure( + message: ConsultantMessage, + showsAuthor: Bool, + roundsAllCorners: Bool, + theme: ConsultantTheme = .defaultTheme, + authorNameOverride: String? = nil + ) { + let isOutgoing = message.fromMe == true + let textColor = MessageBubbleStyle.textColor( + isOutgoing: isOutgoing, + theme: theme + ) + let secondaryColor = MessageBubbleStyle.secondaryColor( + isOutgoing: isOutgoing, + theme: theme + ) + + bubbleView.configure( + isOutgoing: isOutgoing, + roundsAllCorners: roundsAllCorners, + theme: theme + ) + + authorLabel.text = showsAuthor + ? authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + : nil + authorLabel.isHidden = !showsAuthor + authorLabel.textColor = textColor + + orderNumberLabel.text = message.orderNumberLabel + orderNumberLabel.textColor = textColor + orderNumberLabel.isHidden = message.orderNumberLabel?.isEmpty != false + + rebuildOrderContent( + message: message, + textColor: textColor, + linkColor: theme.brandColor, + secondaryColor: secondaryColor + ) + + metadataView.configure( + message: message, + tintColor: secondaryColor, + timestampAccessibilityIdentifier: "orderMessageMetadataLabel", + statusAccessibilityIdentifier: "orderMessageStatusImageView" + ) + } + + /// Refreshes only the outgoing delivery status without rebuilding order content. + package func updateDeliveryStatus( + message: ConsultantMessage, + theme: ConsultantTheme = .defaultTheme + ) { + metadataView.updateDeliveryStatus( + message: message, + tintColor: MessageBubbleStyle.secondaryColor( + isOutgoing: message.fromMe == true, + theme: theme + ), + statusAccessibilityIdentifier: "orderMessageStatusImageView" + ) + } + + private func rebuildOrderContent( + message: ConsultantMessage, + textColor: UIColor, + linkColor: UIColor, + secondaryColor: UIColor + ) { + clearOrderContent() + + message.orderDisplayItems.forEach { item in + orderContentStackView.addArrangedSubview( + makeOrderItemView( + item: item, + textColor: textColor, + linkColor: linkColor, + secondaryColor: secondaryColor + ) + ) + } + + message.orderDisplayFields.forEach { field in + orderContentStackView.addArrangedSubview( + makeFieldView( + field: field, + textColor: textColor, + secondaryColor: secondaryColor + ) + ) + } + + orderContentStackView.isHidden = orderContentStackView.arrangedSubviews.isEmpty + } + + private func makeOrderItemView( + item: MessageOrderDisplayItem, + textColor: UIColor, + linkColor: UIColor, + secondaryColor: UIColor + ) -> UIView { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .fill + stackView.distribution = .fill + stackView.spacing = OrderMessageLayout.innerSpacing + + let nameView = makeOrderItemNameView( + item: item, + textColor: textColor, + linkColor: linkColor + ) + + let detailsLabel = UILabel() + detailsLabel.font = MessageBubbleStyle.regularCompactFont + detailsLabel.textColor = textColor + detailsLabel.numberOfLines = 1 + detailsLabel.lineBreakMode = .byTruncatingTail + detailsLabel.text = item.details + detailsLabel.isHidden = item.details.isEmpty + detailsLabel.accessibilityIdentifier = "orderMessageItemDetailsLabel" + + [nameView, detailsLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview($0) + } + + return stackView + } + + private func makeOrderItemNameView( + item: MessageOrderDisplayItem, + textColor: UIColor, + linkColor: UIColor + ) -> UIView { + let title = "\(item.numberLabel) \(item.name)" + + guard let url = item.url else { + let label = UILabel() + label.font = MessageBubbleStyle.messageFont + label.textColor = textColor + label.numberOfLines = Int(OrderMessageLayout.itemNameMaxLines) + label.lineBreakMode = .byTruncatingTail + label.text = title + label.accessibilityIdentifier = "orderMessageItemNameLabel" + + return label + } + + let linkView = OrderItemLinkView() + linkView.configure( + title: item.name, + prefix: "\(item.numberLabel) ", + url: url, + textColor: textColor, + linkColor: linkColor + ) + linkView.accessibilityIdentifier = "orderMessageItemNameButton" + linkView.addTarget(self, action: #selector(openOrderItemURL(_:)), for: .touchUpInside) + + return linkView + } + + @objc + private func openOrderItemURL(_ sender: OrderItemLinkView) { + guard let url = sender.url, url.scheme?.isEmpty == false else { + return + } + + UIApplication.shared.open(url, options: [:]) + } + + private func makeFieldView( + field: MessageOrderDisplayField, + textColor: UIColor, + secondaryColor: UIColor + ) -> UIView { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .fill + stackView.distribution = .fill + stackView.spacing = OrderMessageLayout.innerSpacing + + let titleLabel = UILabel() + titleLabel.font = MessageBubbleStyle.semiboldMediumFont + titleLabel.textColor = textColor + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.text = field.title + titleLabel.accessibilityIdentifier = "orderMessageFieldTitleLabel" + + let valueLabel = UILabel() + valueLabel.font = MessageBubbleStyle.regularCompactFont + valueLabel.textColor = secondaryColor + valueLabel.numberOfLines = 0 + valueLabel.lineBreakMode = .byTruncatingTail + valueLabel.text = field.value + valueLabel.accessibilityIdentifier = "orderMessageFieldValueLabel" + + [titleLabel, valueLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview($0) + } + + return stackView + } + + private func clearOrderContent() { + orderContentStackView.arrangedSubviews.forEach { view in + orderContentStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + orderContentStackView.isHidden = false + } + +} + +package extension OrderMessageBubbleView { + /// Calculates the height needed for the order number, order items, fields, and optional author. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth: CGFloat, + showsAuthor: Bool + ) -> CGFloat { + let maxContentWidth = max( + 1, + MessageCollectionViewCell.preferredBubbleWidth( + for: message, collectionWidth: collectionWidth, showsAuthor: showsAuthor + ) - MessageLayout.bubbleHorizontalInset * 2 + ) + let orderNumberHeight = message.orderNumberLabel?.isEmpty == false + ? height( + for: message.orderNumberLabel, + width: maxContentWidth, + font: MessageBubbleStyle.semiboldMediumFont, + maxLines: 1 + ) + : 0 + let itemHeights = message.orderDisplayItems.map { + itemHeight( + item: $0, + width: maxContentWidth + ) + } + let fieldHeights = message.orderDisplayFields.map { + fieldHeight( + field: $0, + width: maxContentWidth + ) + } + + return OrderMessageLayout.preferredHeight( + orderNumberHeight: orderNumberHeight, + itemHeights: itemHeights, + fieldHeights: fieldHeights, + showsAuthor: showsAuthor + ) + } + + private static func itemHeight( + item: MessageOrderDisplayItem, + width: CGFloat + ) -> CGFloat { + let nameHeight = height( + for: "\(item.numberLabel) \(item.name)", + width: width, + font: MessageBubbleStyle.messageFont, + maxLines: Int(OrderMessageLayout.itemNameMaxLines) + ) + let detailsHeight = item.details.isEmpty + ? 0 + : height( + for: item.details, + width: width, + font: MessageBubbleStyle.regularCompactFont, + maxLines: 1 + ) + let spacing = detailsHeight > 0 ? OrderMessageLayout.innerSpacing : 0 + + return ceil( + nameHeight + + spacing + + detailsHeight + ) + } + + private static func fieldHeight( + field: MessageOrderDisplayField, + width: CGFloat + ) -> CGFloat { + let titleHeight = height( + for: field.title, + width: width, + font: MessageBubbleStyle.semiboldMediumFont, + maxLines: 1 + ) + let valueHeight = height( + for: field.value, + width: width, + font: MessageBubbleStyle.regularCompactFont, + maxLines: 0 + ) + + return ceil( + titleHeight + + OrderMessageLayout.innerSpacing + + valueHeight + ) + } + + private static func height( + for text: String?, + width: CGFloat, + font: UIFont, + maxLines: Int + ) -> CGFloat { + guard let text, !text.isEmpty else { + return 0 + } + + let measured = ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, width), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil + ).height + ) + + guard maxLines > 0 else { + return measured + } + + return min(measured, ceil(font.lineHeight * CGFloat(maxLines))) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/ProductMessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/ProductMessageBubbleView.swift new file mode 100644 index 0000000..fbc4ef4 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/ProductMessageBubbleView.swift @@ -0,0 +1,410 @@ +import UIKit + +/// Renders a product message with image, title, price, quantity, author, and metadata. +package final class ProductMessageBubbleView: UIView { + + private lazy var bubbleView: MessageBubbleView = { + let view = MessageBubbleView( + contentInsets: UIEdgeInsets( + top: MessageLayout.bubbleVerticalInset, + left: MessageLayout.bubbleHorizontalInset, + bottom: MessageLayout.bubbleVerticalInset, + right: MessageLayout.bubbleHorizontalInset + ) + ) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.bubbleContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var authorLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.authorFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "productMessageAuthorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let textContentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let textStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = MessageLayout.bubbleContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var textTrailingConstraint: NSLayoutConstraint? + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.messageFont + label.numberOfLines = Int(ProductMessageLayout.titleMaxLines) + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "productMessageTitleLabel" + label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(openProductURL))) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var infoStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = 0 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var priceLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.semiboldCompactFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.accessibilityIdentifier = "productMessagePriceLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var quantityLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.regularCompactFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.accessibilityIdentifier = "productMessageQuantityLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var imageRowView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var imageContainerView: UIView = { + let view = UIView() + view.backgroundColor = MessageBubbleStyle.placeholderBackgroundColor + view.layer.cornerRadius = MessageLayout.contentCornerRadius + view.clipsToBounds = true + view.accessibilityIdentifier = "productMessageImageContainerView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var imageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.accessibilityIdentifier = "productMessageImageView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var placeholderImageView: UIImageView = { + let view = UIImageView() + view.image = ConsultantImageResource.photo.image + view.tintColor = MessageBubbleStyle.placeholderIconColor + view.contentMode = .scaleAspectFit + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var metadataView: MetadataView = { + let view = MetadataView() + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentCompressionResistancePriority(.required, for: .vertical) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var imageLoadTask: ImageLoadTask? + private var currentImageURL: URL? + private var productURL: URL? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Cancels image loading and clears product, author, and metadata state before reuse. + package func prepareForReuse() { + imageLoadTask?.cancel() + imageLoadTask = nil + currentImageURL = nil + imageView.image = nil + placeholderImageView.isHidden = false + authorLabel.text = nil + authorLabel.isHidden = false + titleLabel.text = nil + productURL = nil + titleLabel.isUserInteractionEnabled = false + titleLabel.accessibilityTraits = .staticText + priceLabel.text = nil + quantityLabel.text = nil + priceLabel.isHidden = false + quantityLabel.isHidden = false + infoStackView.isHidden = false + metadataView.prepareForReuse() + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + addSubview(bubbleView) + + bubbleView.contentView.addSubview(contentStackView) + + infoStackView.addArrangedSubview(priceLabel) + infoStackView.addArrangedSubview(quantityLabel) + + imageContainerView.addSubview(imageView) + imageContainerView.addSubview(placeholderImageView) + + contentStackView.addArrangedSubview(authorLabel) + contentStackView.addArrangedSubview(imageRowView) + contentStackView.addArrangedSubview(textContentView) + textContentView.addSubview(textStackView) + textStackView.addArrangedSubview(titleLabel) + textStackView.addArrangedSubview(infoStackView) + contentStackView.addArrangedSubview(metadataView) + + imageRowView.addSubview(imageContainerView) + } + + private func setupConstraints() { + let textTrailingConstraint = textStackView.trailingAnchor.constraint(equalTo: textContentView.trailingAnchor) + self.textTrailingConstraint = textTrailingConstraint + NSLayoutConstraint.activate([ + textStackView.topAnchor.constraint(equalTo: textContentView.topAnchor), + textStackView.bottomAnchor.constraint(equalTo: textContentView.bottomAnchor), + textStackView.leadingAnchor.constraint(equalTo: textContentView.leadingAnchor), + textTrailingConstraint, + bubbleView.topAnchor.constraint(equalTo: topAnchor), + bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor), + bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor), + bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentStackView.topAnchor.constraint(equalTo: bubbleView.contentView.topAnchor), + contentStackView.leadingAnchor.constraint(equalTo: bubbleView.contentView.leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: bubbleView.contentView.trailingAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bubbleView.contentView.bottomAnchor), + + imageRowView.heightAnchor.constraint(equalToConstant: ProductMessageLayout.imageSize), + + imageContainerView.topAnchor.constraint(equalTo: imageRowView.topAnchor), + imageContainerView.leadingAnchor.constraint(equalTo: imageRowView.leadingAnchor), + imageContainerView.bottomAnchor.constraint(equalTo: imageRowView.bottomAnchor), + imageContainerView.widthAnchor.constraint(equalToConstant: ProductMessageLayout.imageSize), + + imageView.topAnchor.constraint(equalTo: imageContainerView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: imageContainerView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: imageContainerView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: imageContainerView.bottomAnchor), + + placeholderImageView.centerXAnchor.constraint(equalTo: imageContainerView.centerXAnchor), + placeholderImageView.centerYAnchor.constraint(equalTo: imageContainerView.centerYAnchor), + placeholderImageView.widthAnchor.constraint(equalToConstant: 32), + placeholderImageView.heightAnchor.constraint(equalToConstant: 32), + + metadataView.heightAnchor.constraint( + greaterThanOrEqualToConstant: MessageLayout.secondaryLineHeight + ) + ]) + } + + /// Applies product display data, author visibility, bubble shape, colors, image loading, and metadata. + package func configure( + message: ConsultantMessage, + showsAuthor: Bool, + roundsAllCorners: Bool, + theme: ConsultantTheme = .defaultTheme, + authorNameOverride: String? = nil, + imageWorkers: [String] = [] + ) { + let isOutgoing = message.fromMe == true + textTrailingConstraint?.constant = -ProductMessageLayout.additionalTextTrailingInset(isOutgoing: isOutgoing) + let textColor = MessageBubbleStyle.textColor( + isOutgoing: isOutgoing, + theme: theme + ) + let secondaryColor = MessageBubbleStyle.secondaryColor( + isOutgoing: isOutgoing, + theme: theme + ) + + bubbleView.configure( + isOutgoing: isOutgoing, + roundsAllCorners: roundsAllCorners, + theme: theme + ) + authorLabel.text = showsAuthor + ? authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + : nil + authorLabel.isHidden = !showsAuthor + authorLabel.textColor = textColor + + titleLabel.text = message.productDisplayName + productURL = message.product?.url + .flatMap { URL(string: $0.trimmingCharacters(in: .whitespacesAndNewlines)) } + .flatMap { $0.scheme?.isEmpty == false ? $0 : nil } + titleLabel.textColor = productURL == nil ? textColor : theme.brandColor + titleLabel.isUserInteractionEnabled = productURL != nil + titleLabel.accessibilityTraits = productURL == nil ? .staticText : .link + + priceLabel.text = message.productPriceLabel + priceLabel.textColor = textColor + priceLabel.isHidden = message.productPriceLabel?.isEmpty != false + + quantityLabel.text = message.productQuantityLabel + quantityLabel.textColor = quantityColor( + message: message, + isOutgoing: isOutgoing, + theme: theme + ) + quantityLabel.isHidden = message.productQuantityLabel?.isEmpty != false + infoStackView.isHidden = priceLabel.isHidden && quantityLabel.isHidden + + metadataView.configure( + message: message, + tintColor: secondaryColor, + timestampAccessibilityIdentifier: "productMessageMetadataLabel", + statusAccessibilityIdentifier: "productMessageStatusImageView" + ) + loadImage( + from: ProductImagePreviewURL.make( + imageURLString: message.product?.img, + imageWorkers: imageWorkers + ) + ) + } + + /// Refreshes only the outgoing delivery status without rebuilding product content. + package func updateDeliveryStatus( + message: ConsultantMessage, + theme: ConsultantTheme = .defaultTheme + ) { + metadataView.updateDeliveryStatus( + message: message, + tintColor: MessageBubbleStyle.secondaryColor( + isOutgoing: message.fromMe == true, + theme: theme + ), + statusAccessibilityIdentifier: "productMessageStatusImageView" + ) + } + + @objc + private func openProductURL() { + guard let productURL else { return } + UIApplication.shared.open(productURL, options: [:]) + } + + private func loadImage(from url: URL?) { + imageLoadTask?.cancel() + imageLoadTask = nil + currentImageURL = url + imageView.image = nil + placeholderImageView.isHidden = false + + guard let url else { + return + } + + imageLoadTask = ImageLoader.shared.loadImage(from: url) { [weak self] image in + guard self?.currentImageURL == url else { + return + } + + self?.imageView.image = image + self?.placeholderImageView.isHidden = image != nil + } + } + + private func quantityColor( + message: ConsultantMessage, + isOutgoing: Bool, + theme: ConsultantTheme + ) -> UIColor { + if isOutgoing { + return theme.mainTextColor + } + + return message.product?.quantity?.isAvailable == false + ? MessageBubbleStyle.outOfStockColor + : MessageBubbleStyle.inStockColor + } + +} + +package extension ProductMessageBubbleView { + /// Calculates the height needed for product content and optional author. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth: CGFloat, + showsAuthor: Bool + ) -> CGFloat { + let maxTitleWidth = max( + 1, + MessageCollectionViewCell.preferredBubbleWidth( + for: message, collectionWidth: collectionWidth, showsAuthor: showsAuthor + ) - MessageLayout.bubbleHorizontalInset * 2 + - ProductMessageLayout.additionalTextTrailingInset(isOutgoing: message.fromMe == true) + ) + let titleHeight = height( + for: message.productDisplayName, + width: maxTitleWidth, + font: MessageBubbleStyle.messageFont + ) + + return ProductMessageLayout.preferredHeight( + titleHeight: titleHeight, + showsAuthor: showsAuthor, + showsPrice: message.productPriceLabel?.isEmpty == false, + showsQuantity: message.productQuantityLabel?.isEmpty == false + ) + } + + private static func height(for text: String, width: CGFloat, font: UIFont) -> CGFloat { + ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, width), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil + ).height + ) + } + +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/QuoteMessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/QuoteMessageBubbleView.swift new file mode 100644 index 0000000..7b3c7fc --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/QuoteMessageBubbleView.swift @@ -0,0 +1,262 @@ +import UIKit + +/// Renders a quote message with a quoted preview, optional body text, author, and metadata. +package final class QuoteMessageBubbleView: UIView { + + package var onQuoteTap: (() -> Void)? + + package var onLinkTap: ((URL) -> Void)? { + didSet { + messageLabel.onLinkTap = onLinkTap + } + } + + private lazy var bubbleView: MessageBubbleView = { + let view = MessageBubbleView(contentInsets: UIEdgeInsets( + top: QuoteMessageLayout.verticalInset, + left: MessageLayout.bubbleHorizontalInset, + bottom: QuoteMessageLayout.verticalInset, + right: MessageLayout.bubbleHorizontalInset + )) + view.accessibilityIdentifier = "quoteMessageBubbleView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.bubbleContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var authorLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.authorFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "quoteMessageAuthorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var quotePreviewView: QuotePreviewView = { + let view = QuotePreviewView() + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleQuoteTap))) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var messageLabel: LinkedMessageTextView = { + let label = LinkedMessageTextView() + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "quoteMessageBodyLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var metadataView: MetadataView = { + let view = MetadataView() + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentCompressionResistancePriority(.required, for: .vertical) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + private var quotePreviewHeightConstraint: NSLayoutConstraint? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Resets quote preview, body text, author, and metadata before reuse. + package func prepareForReuse() { + authorLabel.text = nil + authorLabel.isHidden = false + quotePreviewView.prepareForReuse() + messageLabel.prepareForReuse() + messageLabel.isHidden = false + metadataView.prepareForReuse() + onLinkTap = nil + onQuoteTap = nil + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + addSubview(bubbleView) + + bubbleView.contentView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(authorLabel) + contentStackView.addArrangedSubview(quotePreviewView) + contentStackView.addArrangedSubview(messageLabel) + contentStackView.addArrangedSubview(metadataView) + } + + private func setupConstraints() { + quotePreviewHeightConstraint = quotePreviewView.heightAnchor.constraint( + equalToConstant: QuoteMessageLayout.quoteTextMinHeight + ) + + NSLayoutConstraint.activate([ + bubbleView.topAnchor.constraint(equalTo: topAnchor), + bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor), + bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor), + bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentStackView.topAnchor.constraint(equalTo: bubbleView.contentView.topAnchor), + contentStackView.leadingAnchor.constraint(equalTo: bubbleView.contentView.leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: bubbleView.contentView.trailingAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bubbleView.contentView.bottomAnchor), + quotePreviewHeightConstraint!, + + metadataView.heightAnchor.constraint( + greaterThanOrEqualToConstant: MessageLayout.secondaryLineHeight + ) + ]) + } + + /// Applies quoted content, optional body text, author visibility, bubble shape, colors, and metadata. + package func configure( + message: ConsultantMessage, + showsAuthor: Bool, + roundsAllCorners: Bool, + theme: ConsultantTheme = .defaultTheme, + authorNameOverride: String? = nil + ) { + let isOutgoing = message.fromMe == true + let textColor = MessageBubbleStyle.textColor( + isOutgoing: isOutgoing, + theme: theme + ) + let secondaryColor = MessageBubbleStyle.secondaryColor( + isOutgoing: isOutgoing, + theme: theme + ) + + bubbleView.configure( + isOutgoing: isOutgoing, + roundsAllCorners: roundsAllCorners, + theme: theme + ) + authorLabel.text = showsAuthor + ? authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + : nil + authorLabel.isHidden = !showsAuthor + authorLabel.textColor = textColor + + if let quotePreview = message.quotePreview { + quotePreviewView.configure(preview: quotePreview, textColor: textColor) + quotePreviewView.isHidden = false + quotePreviewHeightConstraint?.constant = QuoteMessageLayout.quotePreviewHeight( + for: quotePreview + ) + } else { + quotePreviewView.isHidden = true + } + + let messageText = message.content?.trimmingCharacters(in: .whitespacesAndNewlines) + messageLabel.configure( + text: messageText, + textColor: textColor, + font: MessageBubbleStyle.messageFont, + linkColor: isOutgoing ? textColor : .systemBlue + ) + messageLabel.isHidden = messageText?.isEmpty != false + metadataView.configure( + message: message, + tintColor: secondaryColor, + timestampAccessibilityIdentifier: "quoteMessageMetadataLabel", + statusAccessibilityIdentifier: "quoteMessageStatusImageView" + ) + } + + @objc private func handleQuoteTap() { + onQuoteTap?() + } + + /// Refreshes only the outgoing delivery status without rebuilding quote content. + package func updateDeliveryStatus( + message: ConsultantMessage, + theme: ConsultantTheme = .defaultTheme + ) { + metadataView.updateDeliveryStatus( + message: message, + tintColor: MessageBubbleStyle.secondaryColor( + isOutgoing: message.fromMe == true, + theme: theme + ), + statusAccessibilityIdentifier: "quoteMessageStatusImageView" + ) + } + +} + +package extension QuoteMessageBubbleView { + /// Calculates the height needed for a quote message, falling back to text message sizing when no preview exists. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth: CGFloat, + showsAuthor: Bool + ) -> CGFloat { + guard let preview = message.quotePreview else { + return TextMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth, + showsAuthor: showsAuthor + ) + } + + let maxBodyWidth = max( + 1, + collectionWidth * MessageLayout.bubbleMaxWidthMultiplier + - MessageLayout.bubbleHorizontalInset * 2 + ) + let bodyText = message.content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let bodyHeight = bodyText.isEmpty + ? 0 + : height( + for: bodyText, + width: maxBodyWidth, + font: MessageBubbleStyle.messageFont + ) + + return QuoteMessageLayout.preferredHeight( + bodyHeight: bodyHeight, + preview: preview, + showsAuthor: showsAuthor + ) + } + + private static func height(for text: String, width: CGFloat, font: UIFont) -> CGFloat { + guard !text.isEmpty else { + return 0 + } + + return ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, width), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil + ).height + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/TextMessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/TextMessageBubbleView.swift new file mode 100644 index 0000000..0558ce4 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/TextMessageBubbleView.swift @@ -0,0 +1,226 @@ +import UIKit + +/// Renders a plain text message with optional author and delivery metadata. +package final class TextMessageBubbleView: UIView { + + package var onLinkTap: ((URL) -> Void)? { + didSet { + messageLabel.onLinkTap = onLinkTap + } + } + + private lazy var bubbleView: MessageBubbleView = { + let view = MessageBubbleView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = MessageLayout.bubbleContentSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var authorLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.authorFont + label.textColor = MessageBubbleStyle.textColor + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.setContentHuggingPriority(.required, for: .vertical) + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "messageAuthorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var messageLabel: LinkedMessageTextView = { + let label = LinkedMessageTextView() + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "messageTextLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var metadataView: MetadataView = { + let view = MetadataView() + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentCompressionResistancePriority(.required, for: .vertical) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var metadataMinimumHeightConstraint = metadataView.heightAnchor.constraint( + greaterThanOrEqualToConstant: MessageLayout.secondaryLineHeight + ) + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Resets text, author, and metadata before the view is reused by a cell. + package func prepareForReuse() { + authorLabel.text = nil + messageLabel.prepareForReuse() + metadataView.prepareForReuse() + authorLabel.isHidden = false + onLinkTap = nil + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + addSubview(bubbleView) + + bubbleView.contentView.addSubview(contentStackView) + + contentStackView.addArrangedSubview(authorLabel) + contentStackView.addArrangedSubview(messageLabel) + contentStackView.addArrangedSubview(metadataView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + bubbleView.topAnchor.constraint(equalTo: topAnchor), + bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor), + bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor), + bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), + + contentStackView.topAnchor.constraint(equalTo: bubbleView.contentView.topAnchor), + contentStackView.leadingAnchor.constraint(equalTo: bubbleView.contentView.leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: bubbleView.contentView.trailingAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bubbleView.contentView.bottomAnchor), + + metadataMinimumHeightConstraint + ]) + } + + /// Applies message text, author visibility, bubble shape, colors, and metadata. + package func configure( + message: ConsultantMessage, + showsAuthor: Bool = true, + roundsAllCorners: Bool = false, + theme: ConsultantTheme = .defaultTheme, + authorNameOverride: String? = nil + ) { + let isOutgoing = message.fromMe == true + let textColor = MessageBubbleStyle.textColor( + isOutgoing: isOutgoing, + theme: theme + ) + let secondaryColor = MessageBubbleStyle.secondaryColor( + isOutgoing: isOutgoing, + theme: theme + ) + + bubbleView.configure( + isOutgoing: isOutgoing, + roundsAllCorners: roundsAllCorners, + theme: theme + ) + authorLabel.text = showsAuthor + ? authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + : nil + authorLabel.isHidden = !showsAuthor + authorLabel.textColor = textColor + let messageText = message.content?.isEmpty == false + ? message.content + : message.messageType.map { "[\($0.rawValue)]" } + messageLabel.configure( + text: messageText, + textColor: textColor, + font: MessageBubbleStyle.messageFont, + linkColor: isOutgoing ? textColor : .systemBlue + ) + metadataView.configure( + message: message, + tintColor: secondaryColor, + timestampAccessibilityIdentifier: "messageMetadataLabel", + statusAccessibilityIdentifier: "messageStatusImageView" + ) + metadataMinimumHeightConstraint.constant = metadataView.isHidden + ? 0 + : MessageLayout.secondaryLineHeight + } + + /// Refreshes only the outgoing delivery status without rebuilding the whole bubble. + package func updateDeliveryStatus( + message: ConsultantMessage, + theme: ConsultantTheme = .defaultTheme + ) { + metadataView.updateDeliveryStatus( + message: message, + tintColor: MessageBubbleStyle.secondaryColor( + isOutgoing: message.fromMe == true, + theme: theme + ), + statusAccessibilityIdentifier: "messageStatusImageView" + ) + } + +} + +package extension TextMessageBubbleView { + + /// Calculates the height needed to render a text message in the current collection width. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth: CGFloat, + showsAuthor: Bool = true + ) -> CGFloat { + guard collectionWidth > 0 else { + return 52 + } + + let maxTextWidth = TextMessageLayout.maxTextWidth(collectionWidth: collectionWidth) + let isOutgoing = message.fromMe == true + let messageText = message.content?.isEmpty == false + ? message.content ?? "" + : message.messageType.map { "[\($0.rawValue)]" } ?? "" + let messageHeight = height( + for: messageText, + width: maxTextWidth, + font: MessageBubbleStyle.messageFont + ) + + return TextMessageLayout.preferredHeight( + messageTextHeight: messageHeight, + isOutgoing: isOutgoing, + showsAuthor: showsAuthor, + showsMetadata: MessageMetadataFormatter.metadataText(for: message) != nil + || (isOutgoing && MessageDeliveryStatus(messageStatus: message.status) != .none) + ) + } + + private static func height(for text: String, width: CGFloat, font: UIFont) -> CGFloat { + guard !text.isEmpty else { + return ceil(font.lineHeight) + } + + return ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, width), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil + ).height + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/FileMessageItemView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/FileMessageItemView.swift new file mode 100644 index 0000000..57a1868 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/FileMessageItemView.swift @@ -0,0 +1,202 @@ +// +// OnlineConsultantFileMessageRowView.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 21.07.2026. +// + +import UIKit + +/// Renders a single file attachment row inside a file message bubble. +package final class FileMessageItemView: UIView { + + private lazy var iconContainerView: UIView = { + let view = UIView() + view.layer.cornerRadius = MessageLayout.contentCornerRadius + view.clipsToBounds = true + view.accessibilityIdentifier = "fileMessageIconContainerView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var iconImageView: UIImageView = { + let view = UIImageView() + view.image = ConsultantImageResource.paperclip.templateImage + view.contentMode = .scaleAspectFit + view.accessibilityIdentifier = "fileMessageIconImageView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var loadingIndicatorView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.hidesWhenStopped = true + view.accessibilityIdentifier = "fileMessageLoadingIndicator" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var textStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = 0 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.semiboldMediumFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingMiddle + label.accessibilityIdentifier = "fileMessageTitleLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var sizeLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.regularSmallFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + label.accessibilityIdentifier = "fileMessageSizeLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var tapButton: UIButton = { + let button = UIButton(type: .custom) + button.backgroundColor = .clear + button.accessibilityIdentifier = "fileMessageTapButton" + button.addTarget( + self, + action: #selector(handleTap), + for: .touchUpInside + ) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + /// Called when the row is tapped and file preview is enabled. + package var onTap: (() -> Void)? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Clears file labels, loading state, tap state, and callback before reuse. + package func prepareForReuse() { + titleLabel.text = nil + sizeLabel.text = nil + sizeLabel.isHidden = false + tapButton.isEnabled = false + tapButton.isHidden = true + setLoading(false) + onTap = nil + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + accessibilityIdentifier = "fileMessageRowView" + + addSubview(iconContainerView) + + iconContainerView.addSubview(iconImageView) + iconContainerView.addSubview(loadingIndicatorView) + + addSubview(textStackView) + + textStackView.addArrangedSubview(titleLabel) + textStackView.addArrangedSubview(sizeLabel) + + addSubview(tapButton) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + iconContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), + iconContainerView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconContainerView.widthAnchor.constraint(equalToConstant: FileMessageLayout.fileIconSize), + iconContainerView.heightAnchor.constraint(equalToConstant: FileMessageLayout.fileIconSize), + + iconImageView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor), + iconImageView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: 22), + iconImageView.heightAnchor.constraint(equalToConstant: 22), + + loadingIndicatorView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor), + loadingIndicatorView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor), + + textStackView.leadingAnchor.constraint( + equalTo: iconContainerView.trailingAnchor, + constant: MessageLayout.contentItemSpacing + ), + textStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + textStackView.centerYAnchor.constraint(equalTo: centerYAnchor), + textStackView.heightAnchor.constraint( + equalToConstant: MessageLayout.contentLineHeight + + MessageLayout.secondaryLineHeight + ), + + titleLabel.heightAnchor.constraint(equalToConstant: MessageLayout.contentLineHeight), + sizeLabel.heightAnchor.constraint(equalToConstant: MessageLayout.secondaryLineHeight), + + tapButton.topAnchor.constraint(equalTo: topAnchor), + tapButton.leadingAnchor.constraint(equalTo: leadingAnchor), + tapButton.trailingAnchor.constraint(equalTo: trailingAnchor), + tapButton.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + /// Applies file title, size, preview availability, and loading state. + package func configure( + item: MessageFileDisplayItem, + isTapEnabled: Bool, + isLoading: Bool = false + ) { + titleLabel.text = item.displayName + sizeLabel.text = item.sizeLabel + sizeLabel.isHidden = item.sizeLabel?.isEmpty != false + tapButton.isEnabled = isTapEnabled + tapButton.isHidden = !isTapEnabled + setLoading(isLoading) + } + + /// Applies row colors derived from the containing incoming or outgoing bubble. + package func configureColors( + isOutgoing _: Bool, + primaryTextColor: UIColor, + sizeTextColor: UIColor, + iconBackgroundColor: UIColor, + iconTintColor: UIColor + ) { + titleLabel.textColor = primaryTextColor + sizeLabel.textColor = sizeTextColor + iconContainerView.backgroundColor = iconBackgroundColor + iconImageView.tintColor = iconTintColor + loadingIndicatorView.color = iconTintColor + } + + @objc private func handleTap() { + onTap?() + } + + private func setLoading(_ isLoading: Bool) { + if isLoading { + loadingIndicatorView.startAnimating() + } else { + loadingIndicatorView.stopAnimating() + } + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/ImageMessageItemView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/ImageMessageItemView.swift new file mode 100644 index 0000000..814c6e1 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/ImageMessageItemView.swift @@ -0,0 +1,373 @@ +// +// ImageMessageItemView.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 21.07.2026. +// + +import UIKit + +/// Renders a single image attachment inside an image message bubble. +package final class ImageMessageItemView: UIView { + + private lazy var imageContainerView: UIView = { + let view = UIView() + view.backgroundColor = MessageBubbleStyle.placeholderBackgroundColor + view.layer.cornerRadius = MessageLayout.contentCornerRadius + view.clipsToBounds = true + view.accessibilityIdentifier = "imageMessageImageContainerView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var imageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.accessibilityIdentifier = "imageMessageImageView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var placeholderImageView: UIImageView = { + let view = UIImageView() + view.image = ConsultantImageResource.photo.image + view.tintColor = MessageBubbleStyle.placeholderIconColor + view.contentMode = .scaleAspectFit + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var metadataOverlayView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(red: 13 / 255, green: 15 / 255, blue: 31 / 255, alpha: 0.4) + view.layer.cornerRadius = 4 + view.clipsToBounds = true + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var metadataView: MetadataView = { + let view = MetadataView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var loadingOverlayView: UIView = { + let view = UIView() + view.backgroundColor = UIColor(white: 0, alpha: 0.28) + view.isHidden = true + view.accessibilityIdentifier = "imageMessageLoadingOverlayView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var loadingIndicatorView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.hidesWhenStopped = true + view.color = .white + view.accessibilityIdentifier = "imageMessageLoadingIndicator" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var retryButton: UIButton = { + let button = UIButton(type: .custom) + button.backgroundColor = UIColor(white: 0, alpha: 0.5) + button.layer.cornerRadius = 18 + button.clipsToBounds = true + button.tintColor = .white + button.setImage(ConsultantImageResource.arrowClockwise.templateImage, for: .normal) + button.isHidden = true + button.accessibilityIdentifier = "imageMessageRetryButton" + button.addTarget(self, action: #selector(handleRetryTap), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var tapButton: UIButton = { + let button = UIButton(type: .custom) + button.backgroundColor = .clear + button.accessibilityIdentifier = "imageMessageTapButton" + button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var widthConstraint: NSLayoutConstraint? + private var heightConstraint: NSLayoutConstraint? + private var imageLoadTask: ImageLoadTask? + private var currentImageURL: URL? + private var sourceImageSize: CGSize? + private var isPreviewTapEnabled = false + private var isThumbnailLoading = false + private var isPreviewLoading = false + private var hasImageLoadError = false + /// Called when the image is tapped and preview is enabled. + package var onTap: (() -> Void)? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Cancels image loading and clears image, metadata, loading, and tap state before reuse. + package func prepareForReuse() { + imageLoadTask?.cancel() + imageLoadTask = nil + currentImageURL = nil + sourceImageSize = nil + imageView.image = nil + placeholderImageView.isHidden = false + metadataView.prepareForReuse() + metadataOverlayView.isHidden = true + isPreviewTapEnabled = false + isThumbnailLoading = false + isPreviewLoading = false + hasImageLoadError = false + updateLoadingState() + updateImageInteractionState() + tapButton.isEnabled = false + tapButton.isHidden = true + onTap = nil + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + addSubview(imageContainerView) + + imageContainerView.addSubview(imageView) + imageContainerView.addSubview(placeholderImageView) + imageContainerView.addSubview(metadataOverlayView) + imageContainerView.addSubview(loadingOverlayView) + imageContainerView.addSubview(tapButton) + imageContainerView.addSubview(retryButton) + + metadataOverlayView.addSubview(metadataView) + + loadingOverlayView.addSubview(loadingIndicatorView) + } + + private func setupConstraints() { + let widthConstraint = imageContainerView.widthAnchor.constraint( + equalToConstant: ImageMessageLayout.contentWidth + ) + let heightConstraint = imageContainerView.heightAnchor.constraint( + equalToConstant: ImageMessageLayout.contentHeight + ) + + self.widthConstraint = widthConstraint + self.heightConstraint = heightConstraint + + NSLayoutConstraint.activate([ + imageContainerView.topAnchor.constraint(equalTo: topAnchor), + imageContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageContainerView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageContainerView.bottomAnchor.constraint(equalTo: bottomAnchor), + widthConstraint, + heightConstraint, + + imageView.topAnchor.constraint(equalTo: imageContainerView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: imageContainerView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: imageContainerView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: imageContainerView.bottomAnchor), + + placeholderImageView.centerXAnchor.constraint(equalTo: imageContainerView.centerXAnchor), + placeholderImageView.centerYAnchor.constraint(equalTo: imageContainerView.centerYAnchor), + placeholderImageView.widthAnchor.constraint(equalToConstant: 36), + placeholderImageView.heightAnchor.constraint(equalToConstant: 36), + + metadataOverlayView.trailingAnchor.constraint(equalTo: imageContainerView.trailingAnchor, constant: -4), + metadataOverlayView.bottomAnchor.constraint(equalTo: imageContainerView.bottomAnchor, constant: -4), + metadataOverlayView.heightAnchor.constraint( + equalToConstant: MessageLayout.secondaryLineHeight + ), + + loadingOverlayView.topAnchor.constraint(equalTo: imageContainerView.topAnchor), + loadingOverlayView.leadingAnchor.constraint(equalTo: imageContainerView.leadingAnchor), + loadingOverlayView.trailingAnchor.constraint(equalTo: imageContainerView.trailingAnchor), + loadingOverlayView.bottomAnchor.constraint(equalTo: imageContainerView.bottomAnchor), + + loadingIndicatorView.centerXAnchor.constraint(equalTo: loadingOverlayView.centerXAnchor), + loadingIndicatorView.centerYAnchor.constraint(equalTo: loadingOverlayView.centerYAnchor), + + tapButton.topAnchor.constraint(equalTo: imageContainerView.topAnchor), + tapButton.leadingAnchor.constraint(equalTo: imageContainerView.leadingAnchor), + tapButton.trailingAnchor.constraint(equalTo: imageContainerView.trailingAnchor), + tapButton.bottomAnchor.constraint(equalTo: imageContainerView.bottomAnchor), + + retryButton.centerXAnchor.constraint(equalTo: imageContainerView.centerXAnchor), + retryButton.centerYAnchor.constraint(equalTo: imageContainerView.centerYAnchor), + retryButton.widthAnchor.constraint(equalToConstant: 36), + retryButton.heightAnchor.constraint(equalToConstant: 36), + + metadataView.topAnchor.constraint(equalTo: metadataOverlayView.topAnchor), + metadataView.leadingAnchor.constraint( + equalTo: metadataOverlayView.leadingAnchor, + constant: ImageMessageLayout.metaOverlayHorizontalInset + ), + metadataView.trailingAnchor.constraint( + equalTo: metadataOverlayView.trailingAnchor, + constant: -ImageMessageLayout.metaOverlayHorizontalInset + ), + metadataView.bottomAnchor.constraint(equalTo: metadataOverlayView.bottomAnchor) + ]) + } + + /// Applies image source, metadata visibility, preview tap state, loading state, and image worker hosts. + package func configure( + item: MessageFileDisplayItem, + message: ConsultantMessage, + showsMetadata: Bool, + imageWorkers: [String], + isTapEnabled: Bool, + isLoading: Bool + ) { + sourceImageSize = item.sourceImageSize + loadImage(from: Self.displayImageURL(item.urlString, imageWorkers: imageWorkers)) + isPreviewTapEnabled = isTapEnabled + setPreviewLoading(isLoading) + + if showsMetadata { + metadataView.configure( + message: message, + tintColor: .white, + timestampAccessibilityIdentifier: "imageMessageMetadataOverlayLabel", + statusAccessibilityIdentifier: "imageMessageStatusImageView" + ) + metadataOverlayView.isHidden = metadataView.isHidden + } else { + metadataView.prepareForReuse() + metadataOverlayView.isHidden = true + } + } + + /// Refreshes delivery metadata shown in the image overlay. + package func updateDeliveryStatus( + message: ConsultantMessage, + statusAccessibilityIdentifier: String + ) { + metadataView.updateDeliveryStatus( + message: message, + tintColor: .white, + statusAccessibilityIdentifier: statusAccessibilityIdentifier + ) + metadataOverlayView.isHidden = metadataView.isHidden + } + + /// Updates image dimensions after the containing bubble width is known. + package func updateSize(maxBubbleWidth: CGFloat) { + let imageSize = ImageMessageLayout.imageSize( + maxBubbleWidth: maxBubbleWidth, + sourceSize: sourceImageSize + ) + widthConstraint?.constant = imageSize.width + heightConstraint?.constant = imageSize.height + } + + private func setThumbnailLoading(_ isLoading: Bool) { + isThumbnailLoading = isLoading + updateLoadingState() + updateImageInteractionState() + } + + private func setPreviewLoading(_ isLoading: Bool) { + isPreviewLoading = isLoading + updateLoadingState() + updateImageInteractionState() + } + + private func updateLoadingState() { + let isLoading = isThumbnailLoading || isPreviewLoading + loadingOverlayView.isHidden = !isLoading + if isLoading { + loadingIndicatorView.startAnimating() + } else { + loadingIndicatorView.stopAnimating() + } + } + + private func updateImageInteractionState() { + let canRetry = hasImageLoadError + && currentImageURL != nil + && !isThumbnailLoading + && !isPreviewLoading + retryButton.isHidden = !canRetry + tapButton.isEnabled = isPreviewTapEnabled && !canRetry + tapButton.isHidden = !tapButton.isEnabled + } + + private func loadImage(from url: URL?) { + imageLoadTask?.cancel() + imageLoadTask = nil + currentImageURL = url + imageView.image = nil + placeholderImageView.isHidden = false + hasImageLoadError = false + updateImageInteractionState() + + guard let url else { + setThumbnailLoading(false) + return + } + + setThumbnailLoading(true) + imageLoadTask = ImageLoader.shared.loadImage(from: url) { [weak self] image in + guard self?.currentImageURL == url else { + return + } + + self?.imageLoadTask = nil + self?.imageView.image = image + self?.placeholderImageView.isHidden = image != nil + self?.hasImageLoadError = image == nil + self?.setThumbnailLoading(false) + } + } + + @objc private func handleTap() { + onTap?() + } + + @objc private func handleRetryTap() { + loadImage(from: currentImageURL) + } +} + +package extension ImageMessageItemView { + + private static func displayImageURL( + _ urlString: String?, + imageWorkers: [String] + ) -> URL? { + guard let urlString else { + return nil + } + + let trimmedURLString = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURLString.isEmpty else { + return nil + } + + let url = URL(string: trimmedURLString) + guard url?.scheme == "http" || url?.scheme == "https" || trimmedURLString.hasPrefix("//") else { + return url + } + + return ProductImagePreviewURL.make( + imageURLString: trimmedURLString, + imageWorkers: imageWorkers, + resize: "304x-" + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/LinkedMessageTextView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/LinkedMessageTextView.swift new file mode 100644 index 0000000..d1f8c94 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/LinkedMessageTextView.swift @@ -0,0 +1,128 @@ +import UIKit + +/// Displays message text like a label while making plain URLs and emails tappable. +package final class LinkedMessageTextView: UITextView { + + package var onLinkTap: ((URL) -> Void)? + + private var currentLinkTextColor = UIColor.systemBlue + + package override var canBecomeFirstResponder: Bool { false } + + /// Leaves long presses to the bubble's context menu while retaining link taps. + package override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard !(gestureRecognizer is UILongPressGestureRecognizer) else { + return false + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + + package override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + package func configure( + text: String?, + textColor: UIColor, + font: UIFont, + linkColor: UIColor = .systemBlue, + textAlignment: NSTextAlignment = .natural + ) { + self.font = font + self.textColor = textColor + self.textAlignment = textAlignment + currentLinkTextColor = linkColor + attributedText = Self.attributedText( + text: text ?? "", + textColor: textColor, + font: font, + linkColor: linkColor + ) + } + + package func prepareForReuse() { + text = nil + attributedText = nil + onLinkTap = nil + } + + private func setup() { + backgroundColor = .clear + delegate = self + isEditable = false + isScrollEnabled = false + isSelectable = true + textContainerInset = .zero + textContainer.lineFragmentPadding = 0 + dataDetectorTypes = [] + setContentCompressionResistancePriority(.required, for: .vertical) + linkTextAttributes = [ + .foregroundColor: currentLinkTextColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + } + + private static func attributedText( + text: String, + textColor: UIColor, + font: UIFont, + linkColor: UIColor + ) -> NSAttributedString { + let attributedText = NSMutableAttributedString( + string: text, + attributes: [ + .font: font, + .foregroundColor: textColor + ] + ) + plainLinks(in: text).forEach { link in + attributedText.addAttributes( + [ + .link: link.url, + .foregroundColor: linkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ], + range: link.range + ) + } + + return attributedText + } + + private static func plainLinks(in text: String) -> [(range: NSRange, url: URL)] { + guard !text.isEmpty else { + return [] + } + + let nsText = text as NSString + let fullRange = NSRange(location: 0, length: nsText.length) + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + + return detector?.matches(in: text, options: [], range: fullRange).compactMap { match in + guard let url = match.url else { + return nil + } + + return (match.range, url) + } ?? [] + } +} + +extension LinkedMessageTextView: UITextViewDelegate { + + package func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + onLinkTap?(URL) + return false + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MessageBubbleView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MessageBubbleView.swift new file mode 100644 index 0000000..7b52f26 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MessageBubbleView.swift @@ -0,0 +1,134 @@ +import UIKit + +/// Provides the shared rounded bubble container used by message-specific bubble views. +package final class MessageBubbleView: UIView { + + /// The inset content container where a message-specific bubble view places its arranged content. + package private(set) lazy var contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var contentInsets: UIEdgeInsets + private var theme = ConsultantTheme.defaultTheme + private var contentTopConstraint: NSLayoutConstraint? + private var contentLeadingConstraint: NSLayoutConstraint? + private var contentTrailingConstraint: NSLayoutConstraint? + private var contentBottomConstraint: NSLayoutConstraint? + + package init(contentInsets: UIEdgeInsets = UIEdgeInsets( + top: MessageLayout.bubbleVerticalInset, + left: MessageLayout.bubbleHorizontalInset, + bottom: MessageLayout.bubbleVerticalInset, + right: MessageLayout.bubbleHorizontalInset + )) { + self.contentInsets = contentInsets + super.init(frame: .zero) + setup() + } + + package override init(frame: CGRect) { + self.contentInsets = UIEdgeInsets( + top: MessageLayout.bubbleVerticalInset, + left: MessageLayout.bubbleHorizontalInset, + bottom: MessageLayout.bubbleVerticalInset, + right: MessageLayout.bubbleHorizontalInset + ) + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + self.contentInsets = UIEdgeInsets( + top: MessageLayout.bubbleVerticalInset, + left: MessageLayout.bubbleHorizontalInset, + bottom: MessageLayout.bubbleVerticalInset, + right: MessageLayout.bubbleHorizontalInset + ) + super.init(coder: coder) + setup() + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + layer.cornerRadius = 12 + clipsToBounds = true + + addSubview(contentView) + } + + private func setupConstraints() { + let contentTopConstraint = contentView.topAnchor.constraint(equalTo: topAnchor, constant: contentInsets.top) + let contentLeadingConstraint = contentView.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: contentInsets.left + ) + let contentTrailingConstraint = contentView.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -contentInsets.right + ) + let contentBottomConstraint = contentView.bottomAnchor.constraint( + equalTo: bottomAnchor, + constant: -contentInsets.bottom + ) + + self.contentTopConstraint = contentTopConstraint + self.contentLeadingConstraint = contentLeadingConstraint + self.contentTrailingConstraint = contentTrailingConstraint + self.contentBottomConstraint = contentBottomConstraint + + NSLayoutConstraint.activate([ + contentTopConstraint, + contentLeadingConstraint, + contentTrailingConstraint, + contentBottomConstraint + ]) + } + + /// Applies incoming or outgoing bubble colors, corner radius, and tail corner masking. + package func configure( + isOutgoing: Bool, + roundsAllCorners: Bool = false, + theme: ConsultantTheme = .defaultTheme + ) { + self.theme = theme + backgroundColor = MessageBubbleStyle.bubbleColor( + isOutgoing: isOutgoing, + theme: theme + ) + layer.cornerRadius = theme.roundedElements ? 12 : 4 + layer.maskedCorners = roundsAllCorners + ? Self.allCorners + : tailCorners(isOutgoing: isOutgoing) + } + + /// Updates the content insets by changing the internal content constraints. + package func setContentInsets(_ contentInsets: UIEdgeInsets) { + self.contentInsets = contentInsets + contentTopConstraint?.constant = contentInsets.top + contentLeadingConstraint?.constant = contentInsets.left + contentTrailingConstraint?.constant = -contentInsets.right + contentBottomConstraint?.constant = -contentInsets.bottom + } + + private func tailCorners(isOutgoing: Bool) -> CACornerMask { + isOutgoing + ? [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner] + : [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner] + } +} + +package extension MessageBubbleView { + + private static let allCorners: CACornerMask = [ + .layerMinXMinYCorner, + .layerMaxXMinYCorner, + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MetadataView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MetadataView.swift new file mode 100644 index 0000000..5ee80a9 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/MetadataView.swift @@ -0,0 +1,277 @@ +import UIKit + +/// Renders message timestamp and outgoing delivery status metadata. +package final class MetadataView: UIView { + + private lazy var stackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .center + view.distribution = .fill + view.spacing = MessageLayout.compactSpacing + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var timestampLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.metadataFont + label.textAlignment = .right + label.numberOfLines = 1 + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var statusImageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFit + view.setContentHuggingPriority(.required, for: .horizontal) + view.setContentCompressionResistancePriority(.required, for: .horizontal) + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var showsStatusIcon = false + + package override var intrinsicContentSize: CGSize { + let timestampSize = timestampLabel.isHidden + ? .zero + : timestampLabel.intrinsicContentSize + let statusSize = statusImageView.isHidden + ? .zero + : CGSize( + width: Self.statusIconSize, + height: Self.statusIconSize + ) + let spacing = timestampSize.width > 0 && statusSize.width > 0 + ? MessageLayout.compactSpacing + : 0 + + return CGSize( + width: ceil(timestampSize.width + spacing + statusSize.width), + height: ceil(max(timestampSize.height, statusSize.height)) + ) + } + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Clears timestamp, status icon, and intrinsic size state before reuse. + package func prepareForReuse() { + timestampLabel.text = nil + timestampLabel.isHidden = false + statusImageView.image = nil + statusImageView.isHidden = true + showsStatusIcon = false + invalidateIntrinsicContentSize() + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + backgroundColor = .clear + + stackView.addArrangedSubview(timestampLabel) + stackView.addArrangedSubview(statusImageView) + + addSubview(stackView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + + statusImageView.widthAnchor.constraint( + equalToConstant: Self.statusIconSize + ), + statusImageView.heightAnchor.constraint( + equalToConstant: Self.statusIconSize + ) + ]) + } + + /// Applies timestamp and outgoing delivery status for a message. + package func configure( + message: ConsultantMessage, + tintColor: UIColor, + timestampAccessibilityIdentifier: String, + statusAccessibilityIdentifier: String + ) { + let timestamp = MessageMetadataFormatter.metadataText(for: message) + let status = message.fromMe == true + ? MessageDeliveryStatus(messageStatus: message.status) + : .none + + configure( + timestamp: timestamp, + status: status, + tintColor: tintColor, + timestampAccessibilityIdentifier: timestampAccessibilityIdentifier, + statusAccessibilityIdentifier: statusAccessibilityIdentifier + ) + } + + /// Updates only the outgoing delivery status icon and tint. + package func updateDeliveryStatus( + message: ConsultantMessage, + tintColor: UIColor, + statusAccessibilityIdentifier: String + ) { + let status = message.fromMe == true + ? MessageDeliveryStatus(messageStatus: message.status) + : .none + + let image = Self.statusImage(for: status) + statusImageView.image = image + statusImageView.tintColor = Self.statusTintColor( + for: status, + defaultTintColor: tintColor + ) + statusImageView.isHidden = image == nil + statusImageView.accessibilityIdentifier = statusAccessibilityIdentifier + showsStatusIcon = image != nil + + isHidden = timestampLabel.isHidden && statusImageView.isHidden + invalidateIntrinsicContentSize() + } + + private func configure( + timestamp: String?, + status: MessageDeliveryStatus, + tintColor: UIColor, + timestampAccessibilityIdentifier: String, + statusAccessibilityIdentifier: String + ) { + timestampLabel.text = timestamp + timestampLabel.textColor = tintColor + timestampLabel.isHidden = timestamp?.isEmpty != false + timestampLabel.accessibilityIdentifier = timestampAccessibilityIdentifier + + let image = Self.statusImage(for: status) + statusImageView.image = image + statusImageView.tintColor = Self.statusTintColor( + for: status, + defaultTintColor: tintColor + ) + statusImageView.isHidden = image == nil + statusImageView.accessibilityIdentifier = statusAccessibilityIdentifier + showsStatusIcon = image != nil + + isHidden = timestampLabel.isHidden && statusImageView.isHidden + invalidateIntrinsicContentSize() + } +} + +package extension MetadataView { + + private static let statusIconSize: CGFloat = 12 + + private static func statusImage(for status: MessageDeliveryStatus) -> UIImage? { + switch status { + case .none: + return nil + case .sending: + return ConsultantImageResource.clock.templateImage + case .delivered: + return checkmarkImage(count: 1) + case .read: + return checkmarkImage(count: 2) + case .error: + return ConsultantImageResource.errorCircle.templateImage + } + } + + private static func statusTintColor( + for status: MessageDeliveryStatus, + defaultTintColor: UIColor + ) -> UIColor { + switch status { + case .error: + return UIColor(red: 255 / 255, green: 83 / 255, blue: 83 / 255, alpha: 1) + case .none, .sending, .delivered, .read: + return defaultTintColor + } + } + + private static func checkmarkImage(count: Int) -> UIImage { + let size = CGSize( + width: statusIconSize, + height: statusIconSize + ) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { _ in + UIColor.black.setStroke() + + for index in 0.. CGFloat { + let timestampWidth = width( + for: MessageMetadataFormatter.metadataText(for: message), + maxWidth: maxWidth, + font: MessageBubbleStyle.metadataFont + ) + let status = message.fromMe == true + ? MessageDeliveryStatus(messageStatus: message.status) + : .none + let statusWidth = status == .none + ? 0 + : statusIconSize + let spacing = timestampWidth > 0 && statusWidth > 0 + ? MessageLayout.compactSpacing + : 0 + + return min(maxWidth, ceil(timestampWidth + spacing + statusWidth)) + } + + private static func width( + for text: String?, + maxWidth: CGFloat, + font: UIFont + ) -> CGFloat { + guard let text, !text.isEmpty else { + return 0 + } + + return ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, maxWidth), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil + ).width + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/OrderItemLinkView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/OrderItemLinkView.swift new file mode 100644 index 0000000..66780e2 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/OrderItemLinkView.swift @@ -0,0 +1,101 @@ +// +// OrderItemLinkView.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 21.07.2026. +// + +import UIKit + +/// Renders a tappable order item title as a plain text link without underline. +package final class OrderItemLinkView: UIControl { + + /// URL opened when the containing order bubble receives this control tap. + package var url: URL? + + private let titleLabel = UILabel() + private var linkRange = NSRange(location: 0, length: 0) + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + package override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.62 : 1 + } + } + + /// Applies link title, URL, color, and accessibility label. + package func configure( + title: String, + prefix: String, + url: URL, + textColor: UIColor, + linkColor: UIColor + ) { + self.url = url + let text = NSMutableAttributedString( + string: prefix + title, + attributes: [ + .font: MessageBubbleStyle.messageFont, + .foregroundColor: textColor + ] + ) + linkRange = NSRange(location: (prefix as NSString).length, length: (title as NSString).length) + text.addAttribute(.foregroundColor, value: linkColor, range: linkRange) + titleLabel.attributedText = text + accessibilityLabel = title + } + + /// Only the product name is interactive; the list number and empty space are plain text. + package override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard super.point(inside: point, with: event), + let text = titleLabel.attributedText, linkRange.length > 0 else { + return false + } + let storage = NSTextStorage(attributedString: text) + let manager = NSLayoutManager() + let container = NSTextContainer(size: titleLabel.bounds.size) + container.lineFragmentPadding = 0 + container.maximumNumberOfLines = titleLabel.numberOfLines + container.lineBreakMode = titleLabel.lineBreakMode + storage.addLayoutManager(manager) + manager.addTextContainer(container) + manager.ensureLayout(for: container) + let textRect = titleLabel.textRect(forBounds: titleLabel.bounds, limitedToNumberOfLines: titleLabel.numberOfLines) + let labelPoint = titleLabel.convert(point, from: self) + let textOriginY = (titleLabel.bounds.height - textRect.height) / 2 + let textPoint = CGPoint(x: labelPoint.x - textRect.minX, y: labelPoint.y - textOriginY) + let glyph = manager.glyphIndex(for: textPoint, in: container) + guard glyph < manager.numberOfGlyphs, + manager.boundingRect(forGlyphRange: NSRange(location: glyph, length: 1), in: container).contains(textPoint) else { + return false + } + return NSLocationInRange(manager.characterIndexForGlyph(at: glyph), linkRange) + } + + private func setup() { + isAccessibilityElement = true + accessibilityTraits = .link + + titleLabel.font = MessageBubbleStyle.messageFont + titleLabel.numberOfLines = Int(OrderMessageLayout.itemNameMaxLines) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/QuotePreviewView.swift b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/QuotePreviewView.swift new file mode 100644 index 0000000..1ba5301 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/BubbleViews/Views/QuotePreviewView.swift @@ -0,0 +1,167 @@ +// +// QuotePreviewView.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 21.07.2026. +// + +import UIKit + +/// Renders text, files, and image thumbnails inside a quote message bubble. +package final class QuotePreviewView: UIView { + + private let barView: UIView = { + let view = UIView() + view.backgroundColor = MessageBubbleStyle.quoteBarColor + view.layer.cornerRadius = 1 + view.clipsToBounds = true + view.accessibilityIdentifier = "quotePreviewBarView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var imageLoadTasks: [ImageLoadTask] = [] + private var configurationID = UUID() + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Cancels thumbnail loading and clears content before reuse or reconfiguration. + package func prepareForReuse() { + configurationID = UUID() + imageLoadTasks.forEach { $0.cancel() } + imageLoadTasks.removeAll() + contentStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + } + + deinit { + imageLoadTasks.forEach { $0.cancel() } + } + + package func configure(preview: MessageQuotePreview, textColor: UIColor = MessageBubbleStyle.textColor) { + prepareForReuse() + switch preview { + case .text(let text): + barView.backgroundColor = MessageBubbleStyle.quoteBarColor + addText(text) + case .attachments(let items, let isImage, let text, let author): + barView.backgroundColor = UIColor(red: 99 / 255, green: 111 / 255, blue: 127 / 255, alpha: 0.47) + for item in items { + addAttachment(item, isImage: isImage, author: author, textColor: textColor) + } + if let text { addText(text) } + } + } + + private func setup() { + accessibilityIdentifier = "quotePreviewView" + addSubview(barView) + addSubview(contentStackView) + NSLayoutConstraint.activate([ + barView.leadingAnchor.constraint(equalTo: leadingAnchor), + barView.topAnchor.constraint(equalTo: topAnchor, constant: 4), + barView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + barView.widthAnchor.constraint(equalToConstant: QuoteMessageLayout.quoteBarWidth), + contentStackView.leadingAnchor.constraint( + equalTo: barView.trailingAnchor, + constant: QuoteMessageLayout.quoteBarContentSpacing + ), + contentStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentStackView.topAnchor.constraint(equalTo: topAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + private func makeLabel(_ text: String, identifier: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = MessageBubbleStyle.regularSmallFont + label.textColor = MessageBubbleStyle.quoteTextColor + label.lineBreakMode = .byTruncatingMiddle + label.accessibilityIdentifier = identifier + return label + } + + private func addText(_ text: String) { + let label = makeLabel(text, identifier: "quotePreviewPrimaryLabel") + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingTail + let row = UIStackView(arrangedSubviews: [label]) + row.alignment = .center + row.heightAnchor.constraint(equalToConstant: QuoteMessageLayout.quoteTextMinHeight).isActive = true + contentStackView.addArrangedSubview(row) + } + + private func addAttachment(_ item: MessageItem, isImage: Bool, author: String, textColor: UIColor) { + let title = makeLabel( + isImage ? ConsultantLocalization.messageQuotePhoto.localized + : item.caption ?? ConsultantLocalization.commonFile.localized, + identifier: "quotePreviewAttachmentTitleLabel" + ) + title.textColor = textColor + let authorLabel = makeLabel(author, identifier: "quotePreviewAuthorLabel") + authorLabel.font = MessageBubbleStyle.authorFont + authorLabel.textColor = textColor + let labels = UIStackView(arrangedSubviews: [authorLabel, title]) + labels.axis = .vertical + authorLabel.heightAnchor.constraint(equalToConstant: QuoteMessageLayout.authorHeight).isActive = true + title.heightAnchor.constraint(equalToConstant: MessageLayout.secondaryLineHeight).isActive = true + let row = UIStackView() + row.alignment = .center + row.spacing = QuoteMessageLayout.thumbnailSpacing + row.heightAnchor.constraint( + equalToConstant: QuoteMessageLayout.attachmentRowHeight(isImage: isImage) + ).isActive = true + let iconContainer = UIView() + iconContainer.backgroundColor = MessageBubbleStyle.placeholderBackgroundColor + iconContainer.layer.cornerRadius = 4 + iconContainer.clipsToBounds = true + row.addArrangedSubview(iconContainer) + NSLayoutConstraint.activate([ + iconContainer.widthAnchor.constraint(equalToConstant: QuoteMessageLayout.thumbnailSize), + iconContainer.heightAnchor.constraint(equalToConstant: QuoteMessageLayout.thumbnailSize) + ]) + let thumbnail = UIImageView(image: isImage + ? ConsultantImageResource.photo.image + : ConsultantImageResource.paperclip.templateImage) + thumbnail.tintColor = MessageBubbleStyle.placeholderIconColor + thumbnail.contentMode = isImage ? .scaleAspectFill : .scaleAspectFit + thumbnail.clipsToBounds = true + thumbnail.accessibilityIdentifier = isImage ? "quotePreviewImageView" : "quotePreviewFileIconView" + thumbnail.translatesAutoresizingMaskIntoConstraints = false + iconContainer.addSubview(thumbnail) + let iconInset: CGFloat = isImage ? 0 : 9 + NSLayoutConstraint.activate([ + thumbnail.topAnchor.constraint(equalTo: iconContainer.topAnchor, constant: iconInset), + thumbnail.leadingAnchor.constraint(equalTo: iconContainer.leadingAnchor, constant: iconInset), + thumbnail.trailingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -iconInset), + thumbnail.bottomAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: -iconInset) + ]) + if isImage { + if let urlString = item.resolvedFileURL, let url = URL(string: urlString) { + let id = configurationID + let task = ImageLoader.shared.loadImage(from: url) { [weak self, weak thumbnail] image in + guard self?.configurationID == id, let image else { return } + thumbnail?.image = image + } + if let task { imageLoadTasks.append(task) } + } + } + row.addArrangedSubview(labels) + contentStackView.addArrangedSubview(row) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/CellReuseIdentifier.swift b/Sources/OnlineConsultant/UI/Cells/CellReuseIdentifier.swift new file mode 100644 index 0000000..9ea6fba --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/CellReuseIdentifier.swift @@ -0,0 +1,5 @@ +package enum CellReuseIdentifier: String { + + case message = "MessageCollectionViewCell" + case inputAttachment = "InputAttachmentCollectionViewCell" +} diff --git a/Sources/OnlineConsultant/UI/Cells/InputAttachmentCollectionViewCell.swift b/Sources/OnlineConsultant/UI/Cells/InputAttachmentCollectionViewCell.swift new file mode 100644 index 0000000..463fda8 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/InputAttachmentCollectionViewCell.swift @@ -0,0 +1,296 @@ +import UIKit + +/// Renders an attachment selected in the message input before it is sent. +package final class InputAttachmentCollectionViewCell: UICollectionViewCell { + + private enum Constants { + static let cornerRadius: CGFloat = 10 + static let removeButtonSize: CGFloat = 24 + static let overlayColor = UIColor.black.withAlphaComponent(0.48) + static let secondaryColor = UIColor(red: 0.39, green: 0.44, blue: 0.50, alpha: 1.0) + } + + private lazy var previewContainerView: UIView = { + let view = UIView() + view.backgroundColor = Constants.secondaryColor.withAlphaComponent(0.08) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var imageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var fileIconImageView: UIImageView = { + let view = UIImageView() + view.image = ConsultantImageResource.document.image + view.tintColor = Constants.secondaryColor.withAlphaComponent(0.72) + view.contentMode = .scaleAspectFit + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var fileExtensionLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 10, weight: .semibold) + label.textColor = Constants.secondaryColor + label.textAlignment = .center + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.7 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var fileNameBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = Constants.overlayColor + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var fileNameLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 10, weight: .medium) + label.textColor = .white + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingMiddle + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + return label + }() + + private lazy var statusOverlayView: UIView = { + let view = UIView() + view.backgroundColor = Constants.overlayColor + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var statusStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .center + view.spacing = 4 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var activityIndicatorView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.color = .white + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var statusImageView: UIImageView = { + let view = UIImageView() + view.tintColor = .white + view.contentMode = .scaleAspectFit + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var statusLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 10, weight: .semibold) + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 1 + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.7 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var retryButton: UIButton = { + let button = UIButton(type: .system) + button.backgroundColor = UIColor.white.withAlphaComponent(0.18) + button.tintColor = .white + button.layer.cornerRadius = Constants.removeButtonSize / 2 + button.setImage(ConsultantImageResource.arrowClockwise.image, for: .normal) + button.accessibilityIdentifier = "retryInputAttachmentButton" + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var removeButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(ConsultantImageResource.close.image, for: .normal) + button.accessibilityIdentifier = "removeInputAttachmentButton" + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(removeButtonTapped), for: .touchUpInside) + return button + }() + + /// Called when the user taps the remove button for this pending attachment. + package var onRemove: (() -> Void)? + /// Called when the user taps retry after an attachment upload failure. + package var onRetry: (() -> Void)? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Clears preview, status, and callbacks before the collection view reuses the cell. + package override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + fileExtensionLabel.text = nil + fileNameLabel.text = nil + activityIndicatorView.stopAnimating() + statusImageView.image = nil + statusLabel.text = nil + onRemove = nil + onRetry = nil + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + contentView.backgroundColor = .clear + contentView.layer.cornerRadius = Constants.cornerRadius + contentView.layer.borderColor = Constants.secondaryColor.withAlphaComponent(0.18).cgColor + contentView.layer.borderWidth = 0.5 + contentView.clipsToBounds = true + + contentView.addSubview(previewContainerView) + contentView.addSubview(fileNameBackgroundView) + fileNameBackgroundView.addSubview(fileNameLabel) + contentView.addSubview(statusOverlayView) + contentView.addSubview(removeButton) + + previewContainerView.addSubview(imageView) + previewContainerView.addSubview(fileIconImageView) + previewContainerView.addSubview(fileExtensionLabel) + + statusOverlayView.addSubview(statusStackView) + + statusStackView.addArrangedSubview(activityIndicatorView) + statusStackView.addArrangedSubview(statusImageView) + statusStackView.addArrangedSubview(statusLabel) + statusStackView.addArrangedSubview(retryButton) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + previewContainerView.topAnchor.constraint(equalTo: contentView.topAnchor), + previewContainerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + previewContainerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + previewContainerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + imageView.topAnchor.constraint(equalTo: previewContainerView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: previewContainerView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: previewContainerView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: previewContainerView.bottomAnchor), + + fileIconImageView.centerXAnchor.constraint(equalTo: previewContainerView.centerXAnchor), + fileIconImageView.centerYAnchor.constraint(equalTo: previewContainerView.centerYAnchor, constant: -18), + fileIconImageView.widthAnchor.constraint(equalToConstant: 26), + fileIconImageView.heightAnchor.constraint(equalToConstant: 30), + + fileExtensionLabel.topAnchor.constraint(equalTo: fileIconImageView.bottomAnchor, constant: -2), + fileExtensionLabel.leadingAnchor.constraint(equalTo: previewContainerView.leadingAnchor, constant: 8), + fileExtensionLabel.trailingAnchor.constraint(equalTo: previewContainerView.trailingAnchor, constant: -8), + + fileNameBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + fileNameBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + fileNameBackgroundView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + fileNameLabel.topAnchor.constraint(equalTo: fileNameBackgroundView.topAnchor, constant: 4), + fileNameLabel.leadingAnchor.constraint(equalTo: fileNameBackgroundView.leadingAnchor, constant: 4), + fileNameLabel.trailingAnchor.constraint(equalTo: fileNameBackgroundView.trailingAnchor, constant: -4), + fileNameLabel.bottomAnchor.constraint(equalTo: fileNameBackgroundView.bottomAnchor, constant: -4), + + statusOverlayView.topAnchor.constraint(equalTo: contentView.topAnchor), + statusOverlayView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusOverlayView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + statusOverlayView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + statusStackView.centerXAnchor.constraint(equalTo: statusOverlayView.centerXAnchor), + statusStackView.centerYAnchor.constraint(equalTo: statusOverlayView.centerYAnchor), + statusStackView.leadingAnchor.constraint(greaterThanOrEqualTo: statusOverlayView.leadingAnchor, constant: 6), + statusStackView.trailingAnchor.constraint(lessThanOrEqualTo: statusOverlayView.trailingAnchor, constant: -6), + + retryButton.widthAnchor.constraint(equalToConstant: Constants.removeButtonSize), + retryButton.heightAnchor.constraint(equalToConstant: Constants.removeButtonSize), + + removeButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + removeButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -4), + removeButton.widthAnchor.constraint(equalToConstant: Constants.removeButtonSize), + removeButton.heightAnchor.constraint(equalToConstant: Constants.removeButtonSize) + ]) + } + + /// Applies file preview, visible file name, upload status, and accessibility label. + package func configure(_ attachment: InputAttachment) { + fileNameLabel.text = attachment.fileName + configurePreview(attachment) + configureStatus(attachment.status) + accessibilityLabel = attachment.fileName + } + + private func configurePreview(_ attachment: InputAttachment) { + let image = attachment.previewData.flatMap(UIImage.init(data:)) + let isImage = attachment.messageType == "image" || attachment.contentType.hasPrefix("image/") + + imageView.image = isImage ? image : nil + imageView.isHidden = imageView.image == nil + fileIconImageView.isHidden = !imageView.isHidden + fileExtensionLabel.isHidden = !imageView.isHidden + fileExtensionLabel.text = fileExtension(from: attachment.fileName) + } + + private func configureStatus(_ status: InputAttachmentStatus) { + activityIndicatorView.isHidden = true + statusImageView.isHidden = true + retryButton.isHidden = true + statusOverlayView.isHidden = false + + switch status { + case .uploading: + activityIndicatorView.isHidden = false + activityIndicatorView.startAnimating() + statusLabel.text = ConsultantLocalization.attachmentStatusLoading.localized + case .uploaded: + statusOverlayView.isHidden = true + statusLabel.text = nil + case .failed: + statusImageView.isHidden = false + statusImageView.image = ConsultantImageResource.errorTriangle.image + statusLabel.text = ConsultantLocalization.commonError.localized + retryButton.isHidden = false + } + } + + private func fileExtension(from fileName: String) -> String { + let pathExtension = (fileName as NSString).pathExtension + + guard !pathExtension.isEmpty else { + return ConsultantLocalization.attachmentFileExtensionFallback.localized + } + + return pathExtension.uppercased() + } + + @objc private func removeButtonTapped() { + onRemove?() + } + + @objc private func retryButtonTapped() { + onRetry?() + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/ContactFormMessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/ContactFormMessageLayout.swift new file mode 100644 index 0000000..805b6a0 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/ContactFormMessageLayout.swift @@ -0,0 +1,22 @@ +import CoreGraphics + +/// Measures contact form message bubbles. +package enum ContactFormMessageLayout { + + /// Default form content width used as the upper bound for bubble measurement. + package static let defaultContentWidth: CGFloat = 312 + + /// Returns the preferred contact form bubble width constrained by the maximum bubble width. + package static func preferredBubbleWidth(maxBubbleWidth: CGFloat) -> CGFloat { + min(maxBubbleWidth, defaultContentWidth + MessageLayout.bubbleHorizontalInset * 2) + } + + /// Returns the full cell height for a contact form message. + package static func preferredHeight( + formHeight: CGFloat + ) -> CGFloat { + ceil(MessageLayout.bubbleVerticalInset * 2 + + formHeight + + MessageLayout.compactSpacing * 2) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/FileMessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/FileMessageLayout.swift new file mode 100644 index 0000000..5611654 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/FileMessageLayout.swift @@ -0,0 +1,64 @@ +import CoreGraphics + +/// Measures file attachment message bubbles. +package enum FileMessageLayout { + + /// Default content width used when file names are shorter than the base file block. + package static let defaultContentWidth: CGFloat = 208 + /// Top inset used when the author line is hidden. + package static let bubbleTopInsetWithoutAuthor: CGFloat = 2.5 + /// Bottom inset specific to compact file bubbles. + package static let bubbleBottomInset: CGFloat = 2.5 + /// Fixed height of one rendered file row. + package static let fileBlockHeight: CGFloat = 50 + /// Fixed size of the file icon container. + package static let fileIconSize: CGFloat = 42 + + /// Returns the preferred bubble width for one or more file rows. + package static func preferredBubbleWidth( + maxBubbleWidth: CGFloat, + fileNameWidths: [CGFloat], + fileSizeWidths: [CGFloat], + authorWidth: CGFloat, + metadataWidth: CGFloat + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let measuredTextWidth = max( + fileNameWidths.max() ?? 0, + fileSizeWidths.max() ?? 0 + ) + let measuredFileWidth = fileIconSize + + MessageLayout.contentItemSpacing + + measuredTextWidth + let contentWidth = min( + maxContentWidth, + max(defaultContentWidth, measuredFileWidth, authorWidth, metadataWidth) + ) + + return ceil(contentWidth + MessageLayout.bubbleHorizontalInset * 2) + } + + /// Returns the full cell height for a file message with the given row count. + package static func preferredHeight( + showsAuthor: Bool, + itemCount: Int = 1 + ) -> CGFloat { + let itemCount = max(1, itemCount) + let authorHeight = showsAuthor + ? MessageLayout.secondaryLineHeight + : 0 + let topInset = showsAuthor + ? MessageLayout.bubbleVerticalInset + : bubbleTopInsetWithoutAuthor + let visibleItemsCount = (showsAuthor ? 1 : 0) + itemCount + 1 + let spacingHeight = CGFloat(visibleItemsCount - 1) * MessageLayout.compactSpacing + let bubbleHeight = topInset + + bubbleBottomInset + + authorHeight + + CGFloat(itemCount) * fileBlockHeight + + MessageLayout.secondaryLineHeight + + spacingHeight + + return ceil(bubbleHeight + MessageLayout.compactSpacing * 2) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/ImageMessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/ImageMessageLayout.swift new file mode 100644 index 0000000..4de1022 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/ImageMessageLayout.swift @@ -0,0 +1,133 @@ +import CoreGraphics + +/// Measures image message bubbles and normalizes image sizes for display. +package enum ImageMessageLayout { + + /// Default image content width used when no source size is available. + package static let contentWidth: CGFloat = 271 + /// Default image content height used when no source size is available. + package static let contentHeight: CGFloat = 162 + /// Horizontal inset used by the metadata overlay inside image content. + package static let metaOverlayHorizontalInset: CGFloat = 2 + + /// Returns a fitted image size constrained by the maximum bubble width. + package static func imageSize( + maxBubbleWidth: CGFloat, + sourceSize: CGSize? = nil + ) -> CGSize { + guard maxBubbleWidth > 0 else { + return CGSize(width: contentWidth, height: contentHeight) + } + + let maxContentWidth = min( + contentWidth, + max(1, maxBubbleWidth - MessageLayout.compactSpacing * 2) + ) + let sourceSize = normalizedSourceSize(sourceSize) + let scale = min( + maxContentWidth / sourceSize.width, + contentWidth / sourceSize.height + ) + let contentWidth = max(1, floor(sourceSize.width * scale)) + + return imageSize(contentWidth: contentWidth, sourceSize: sourceSize) + } + + /// Returns a fitted image size for a collection width. + package static func imageSize( + collectionWidth: CGFloat, + sourceSize: CGSize? = nil + ) -> CGSize { + let maxBubbleWidth = collectionWidth + * MessageLayout.bubbleMaxWidthMultiplier + + return imageSize(maxBubbleWidth: maxBubbleWidth, sourceSize: sourceSize) + } + + /// Returns an image size for an already resolved content width. + package static func imageSize( + contentWidth: CGFloat, + sourceSize: CGSize? = nil + ) -> CGSize { + let width = max(1, contentWidth) + let sourceSize = normalizedSourceSize(sourceSize) + let height = ceil(width * sourceSize.height / sourceSize.width) + + return CGSize(width: ceil(width), height: height) + } + + /// Returns the preferred bubble width for one or more image source sizes. + package static func preferredBubbleWidth( + maxBubbleWidth: CGFloat, + sourceSizes: [CGSize?] = [nil] + ) -> CGFloat { + let imageWidths = normalizedSourceSizes(sourceSizes).map { + imageSize(maxBubbleWidth: maxBubbleWidth, sourceSize: $0).width + } + return ceil( + (imageWidths.max() ?? contentWidth) + + MessageLayout.compactSpacing * 2 + ) + } + + /// Returns the preferred bubble width for a single image in a collection. + package static func preferredBubbleWidth( + collectionWidth: CGFloat, + sourceSize: CGSize? = nil + ) -> CGFloat { + let maxBubbleWidth = collectionWidth + * MessageLayout.bubbleMaxWidthMultiplier + + return preferredBubbleWidth(maxBubbleWidth: maxBubbleWidth, sourceSizes: [sourceSize]) + } + + /// Returns the full cell height for an image message, including optional author and image spacing. + package static func preferredHeight( + collectionWidth: CGFloat, + sourceSizes: [CGSize?] = [nil], + showsAuthor: Bool = false + ) -> CGFloat { + let imageSizes = normalizedSourceSizes(sourceSizes).map { + imageSize(collectionWidth: collectionWidth, sourceSize: $0) + } + let authorHeight = showsAuthor + ? MessageLayout.secondaryLineHeight + : 0 + let authorSpacing = showsAuthor + ? MessageLayout.bubbleContentSpacing + : 0 + let topInset = showsAuthor + ? MessageLayout.bubbleVerticalInset + : MessageLayout.compactSpacing + let imagesHeight = imageSizes.reduce(CGFloat(0)) { $0 + $1.height } + let imagesSpacing = CGFloat(max(0, imageSizes.count - 1)) + * MessageLayout.contentItemSpacing + return ceil( + imagesHeight + + imagesSpacing + + authorHeight + + authorSpacing + + topInset + + MessageLayout.compactSpacing + + MessageLayout.compactSpacing * 2 + ) + } + + private static func normalizedSourceSizes(_ sourceSizes: [CGSize?]) -> [CGSize?] { + sourceSizes.isEmpty ? [nil] : sourceSizes + } + + private static func normalizedSourceSize(_ sourceSize: CGSize?) -> CGSize { + guard + let sourceSize, + sourceSize.width.isFinite, + sourceSize.height.isFinite, + sourceSize.width > 0, + sourceSize.height > 0 + else { + return CGSize(width: contentWidth, height: contentHeight) + } + + return sourceSize + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/MessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/MessageLayout.swift new file mode 100644 index 0000000..fec7a4f --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/MessageLayout.swift @@ -0,0 +1,30 @@ +import UIKit + +/// Shared message layout metrics used by all chat bubble types. +package enum MessageLayout { + + /// Maximum bubble width as a fraction of the available collection width. + package static let bubbleMaxWidthMultiplier: CGFloat = 0.8 + /// Default horizontal content inset inside a bubble. + package static let bubbleHorizontalInset: CGFloat = 10 + /// Default vertical content inset inside a bubble. + package static let bubbleVerticalInset: CGFloat = 10 + /// Default spacing between primary arranged content blocks inside a bubble. + package static let bubbleContentSpacing: CGFloat = 10 + /// Default line height for primary content text. + package static let contentLineHeight: CGFloat = 20 + /// Default line height for secondary text such as author, metadata, and file size. + package static let secondaryLineHeight: CGFloat = 16 + /// Default spacing between closely related content items. + package static let contentItemSpacing: CGFloat = 8 + /// Compact spacing used for small insets and tightly grouped elements. + package static let compactSpacing: CGFloat = 4 + /// Default corner radius for framed bubble content. + package static let contentCornerRadius: CGFloat = 8 + /// Smaller corner radius used when rounded elements are disabled. + package static let compactCornerRadius: CGFloat = 4 + /// Minimum message cell height used to keep very small messages tappable and stable. + package static let minimumCellHeight: CGFloat = 52 + /// Fallback height for embedded images when a source size is unavailable. + package static let embeddedImageFallbackHeight: CGFloat = 128 +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/OrderMessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/OrderMessageLayout.swift new file mode 100644 index 0000000..fffd7ec --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/OrderMessageLayout.swift @@ -0,0 +1,57 @@ +import CoreGraphics + +/// Measures order message bubbles. +package enum OrderMessageLayout { + + /// Default minimum content width retained for outgoing messages. + package static let defaultContentWidth: CGFloat = 251 + /// Spacing between tightly related order item or field labels. + package static let innerSpacing: CGFloat = 2 + /// Maximum number of order item name lines; zero means unlimited. + package static let itemNameMaxLines: CGFloat = 0 + + /// Returns the preferred order bubble width from measured order content and metadata widths. + package static func preferredBubbleWidth( + maxBubbleWidth: CGFloat, + orderContentWidth: CGFloat, + authorWidth: CGFloat, + metadataWidth: CGFloat, + minimumContentWidth: CGFloat = defaultContentWidth + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let contentWidth = min( + maxContentWidth, + max(minimumContentWidth, orderContentWidth, authorWidth, metadataWidth) + ) + + return ceil(contentWidth + MessageLayout.bubbleHorizontalInset * 2) + } + + /// Returns the full cell height for an order message. + package static func preferredHeight( + orderNumberHeight: CGFloat, + itemHeights: [CGFloat], + fieldHeights: [CGFloat], + showsAuthor: Bool + ) -> CGFloat { + let authorHeight = showsAuthor + ? MessageLayout.secondaryLineHeight + : 0 + let visibleBlocksCount = (showsAuthor ? 1 : 0) + + (orderNumberHeight > 0 ? 1 : 0) + + itemHeights.count + + fieldHeights.count + + 1 + let spacingHeight = CGFloat(max(0, visibleBlocksCount - 1)) + * MessageLayout.bubbleContentSpacing + let bubbleHeight = MessageLayout.bubbleVerticalInset * 2 + + authorHeight + + orderNumberHeight + + itemHeights.reduce(0, +) + + fieldHeights.reduce(0, +) + + MessageLayout.secondaryLineHeight + + spacingHeight + + return ceil(bubbleHeight + MessageLayout.compactSpacing * 2) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/ProductMessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/ProductMessageLayout.swift new file mode 100644 index 0000000..2a95e16 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/ProductMessageLayout.swift @@ -0,0 +1,79 @@ +import CoreGraphics + +/// Measures product message bubbles. +package enum ProductMessageLayout { + + /// Default minimum content width retained for outgoing messages. + package static let defaultContentWidth: CGFloat = 214 + /// Right inset of incoming product text measured from the bubble edge in Figma. + private static let incomingTextTrailingInset: CGFloat = 32 + + package static func additionalTextTrailingInset(isOutgoing: Bool) -> CGFloat { + isOutgoing ? 0 : incomingTextTrailingInset - MessageLayout.bubbleHorizontalInset + } + + /// Fixed product image side length. + package static let imageSize: CGFloat = 80 + /// Maximum number of title lines used for height normalization. + package static let titleMaxLines: CGFloat = 2 + + /// Returns the preferred product bubble width from measured text and metadata widths. + package static func preferredBubbleWidth( + maxBubbleWidth: CGFloat, + titleWidth: CGFloat, + priceWidth: CGFloat, + quantityWidth: CGFloat, + authorWidth: CGFloat, + metadataWidth: CGFloat, + minimumContentWidth: CGFloat = defaultContentWidth, + additionalTextTrailingInset: CGFloat = 0 + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let contentWidth = min( + maxContentWidth, + max( + minimumContentWidth, + imageSize, + titleWidth + additionalTextTrailingInset, + priceWidth + additionalTextTrailingInset, + quantityWidth + additionalTextTrailingInset, + authorWidth, + metadataWidth + ) + ) + + return ceil(contentWidth + MessageLayout.bubbleHorizontalInset * 2) + } + + /// Returns the full cell height for a product message. + package static func preferredHeight( + titleHeight: CGFloat, + showsAuthor: Bool, + showsPrice: Bool, + showsQuantity: Bool + ) -> CGFloat { + let authorHeight = showsAuthor + ? MessageLayout.secondaryLineHeight + : 0 + let normalizedTitleHeight = max( + MessageLayout.contentLineHeight, + min(titleHeight, MessageLayout.contentLineHeight * titleMaxLines) + ) + let infoLineCount = (showsPrice ? 1 : 0) + (showsQuantity ? 1 : 0) + let infoHeight = CGFloat(infoLineCount) * MessageLayout.contentLineHeight + let visibleItemsCount = 3 + + (showsAuthor ? 1 : 0) + + (infoLineCount > 0 ? 1 : 0) + let spacingHeight = CGFloat(max(0, visibleItemsCount - 1)) + * MessageLayout.bubbleContentSpacing + let bubbleHeight = MessageLayout.bubbleVerticalInset * 2 + + authorHeight + + normalizedTitleHeight + + infoHeight + + imageSize + + MessageLayout.secondaryLineHeight + + spacingHeight + + return ceil(bubbleHeight + MessageLayout.compactSpacing * 2) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/QuoteMessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/QuoteMessageLayout.swift new file mode 100644 index 0000000..a0e440e --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/QuoteMessageLayout.swift @@ -0,0 +1,74 @@ +import CoreGraphics + +/// Measures quote message bubbles with a quoted preview and optional body text. +package enum QuoteMessageLayout { + + /// Width of the vertical quote indicator. + package static let quoteBarWidth: CGFloat = 2 + /// Spacing between the quote indicator and quoted content. + package static let quoteBarContentSpacing: CGFloat = 10 + /// Minimum height reserved for a text quote preview. + package static let quoteTextMinHeight: CGFloat = 40 + package static let verticalInset: CGFloat = 8 + package static let authorHeight: CGFloat = 20 + package static let thumbnailSize: CGFloat = 40 + package static let thumbnailSpacing: CGFloat = 8 + + /// Returns the preferred quote bubble width from measured content parts. + package static func preferredBubbleWidth( + maxBubbleWidth: CGFloat, + authorWidth: CGFloat, + bodyWidth: CGFloat, + quoteWidth: CGFloat, + metadataWidth: CGFloat + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let contentWidth = min( + maxContentWidth, + max(authorWidth, bodyWidth, quoteWidth, metadataWidth) + ) + + return ceil(contentWidth + MessageLayout.bubbleHorizontalInset * 2) + } + + /// Returns the fixed preview height for a quote preview kind. + package static func quotePreviewHeight(for preview: MessageQuotePreview) -> CGFloat { + switch preview { + case .text: + return quoteTextMinHeight + case .attachments(let items, let isImage, let text, _): + return CGFloat(items.count) * attachmentRowHeight(isImage: isImage) + + (text == nil ? 0 : quoteTextMinHeight) + } + } + + package static func attachmentRowHeight(isImage: Bool) -> CGFloat { + isImage ? 52 : 48 + } + + /// Returns the full cell height for a quote message. + package static func preferredHeight( + bodyHeight: CGFloat, + preview: MessageQuotePreview, + showsAuthor: Bool + ) -> CGFloat { + let authorHeight = showsAuthor + ? authorHeight + : 0 + let quoteHeight = quotePreviewHeight(for: preview) + let bodyHeight = bodyHeight > 0 ? bodyHeight : 0 + let visibleItemsCount = 2 + + (showsAuthor ? 1 : 0) + + (bodyHeight > 0 ? 1 : 0) + let spacingHeight = CGFloat(max(0, visibleItemsCount - 1)) + * MessageLayout.bubbleContentSpacing + let bubbleHeight = verticalInset * 2 + + authorHeight + + quoteHeight + + bodyHeight + + MessageLayout.secondaryLineHeight + + spacingHeight + + return ceil(bubbleHeight + MessageLayout.compactSpacing * 2) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/Layouts/TextMessageLayout.swift b/Sources/OnlineConsultant/UI/Cells/Layouts/TextMessageLayout.swift new file mode 100644 index 0000000..0df2e8f --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/Layouts/TextMessageLayout.swift @@ -0,0 +1,40 @@ +import CoreGraphics + +/// Measures plain text message bubbles. +package enum TextMessageLayout { + + /// Returns the maximum text content width for the given collection width. + package static func maxTextWidth( + collectionWidth: CGFloat + ) -> CGFloat { + max( + 1, + collectionWidth * MessageLayout.bubbleMaxWidthMultiplier + - MessageLayout.bubbleHorizontalInset * 2 + ) + } + + /// Returns the full cell height for a text message, including bubble insets and metadata. + package static func preferredHeight( + messageTextHeight: CGFloat, + isOutgoing: Bool, + showsAuthor: Bool? = nil, + showsMetadata: Bool = true + ) -> CGFloat { + let showsAuthor = showsAuthor ?? !isOutgoing + let authorHeight = showsAuthor ? MessageLayout.secondaryLineHeight : 0 + let visibleItemsCount = 1 + (showsAuthor ? 1 : 0) + (showsMetadata ? 1 : 0) + let spacingHeight = CGFloat(visibleItemsCount - 1) + * MessageLayout.bubbleContentSpacing + let bubbleHeight = MessageLayout.bubbleVerticalInset * 2 + + authorHeight + + ceil(messageTextHeight) + + (showsMetadata ? MessageLayout.secondaryLineHeight : 0) + + spacingHeight + + return max( + MessageLayout.minimumCellHeight, + ceil(bubbleHeight) + MessageLayout.compactSpacing * 2 + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Cells/MessageCollectionViewCell.swift b/Sources/OnlineConsultant/UI/Cells/MessageCollectionViewCell.swift new file mode 100644 index 0000000..730906c --- /dev/null +++ b/Sources/OnlineConsultant/UI/Cells/MessageCollectionViewCell.swift @@ -0,0 +1,1150 @@ +import UIKit + +/// Displays one chat message by selecting and configuring the matching bubble view. +package final class MessageCollectionViewCell: UICollectionViewCell { + + package static let dateSeparatorHeight: CGFloat = 40 + + private lazy var dateSeparatorLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.textAlignment = .center + label.isHidden = true + label.accessibilityIdentifier = "messageDateSeparatorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var bubbleTopConstraint: NSLayoutConstraint? + + private lazy var avatarView: UIView = { + let view = UIView() + view.layer.cornerRadius = 18 + view.clipsToBounds = true + view.accessibilityIdentifier = "messageAvatarView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var avatarImageView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFill + view.clipsToBounds = true + view.isHidden = true + view.accessibilityIdentifier = "messageAvatarImageView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var botAvatarImageView: UIImageView = { + let view = UIImageView() + view.image = ConsultantImageResource.botIcon.templateImage + view.contentMode = .scaleAspectFit + view.tintColor = .black + view.isHidden = true + view.accessibilityIdentifier = "messageBotAvatarImageView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var avatarLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.regularSmallFont + label.textAlignment = .center + label.accessibilityIdentifier = "messageAvatarLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var bubbleContainerView: UIView = { + let view = UIView() + view.accessibilityIdentifier = "messageBubbleContainerView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var textMessageBubbleView: TextMessageBubbleView = { + let view = TextMessageBubbleView() + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var imageMessageBubbleView: ImageMessageBubbleView = { + let view = ImageMessageBubbleView() + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var fileMessageBubbleView: FileMessageBubbleView = { + let view = FileMessageBubbleView() + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var quoteMessageBubbleView: QuoteMessageBubbleView = { + let view = QuoteMessageBubbleView() + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var productMessageBubbleView: ProductMessageBubbleView = { + let view = ProductMessageBubbleView() + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var orderMessageBubbleView: OrderMessageBubbleView = { + let view = OrderMessageBubbleView() + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contactFormMessageBubbleView: ContactFormMessageBubbleView = { + let view = ContactFormMessageBubbleView() + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var activeBubbleView: UIView? + private var activeBubbleConstraints: [NSLayoutConstraint] = [] + private var avatarWidthConstraint: NSLayoutConstraint? + private var bubbleIncomingLeadingConstraint: NSLayoutConstraint? + private var bubbleIncomingNoAvatarLeadingConstraint: NSLayoutConstraint? + private var bubbleOutgoingTrailingConstraint: NSLayoutConstraint? + private var bubbleIncomingTrailingConstraint: NSLayoutConstraint? + private var bubbleOutgoingLeadingConstraint: NSLayoutConstraint? + private var bubbleCenteredXConstraint: NSLayoutConstraint? + private var bubbleWidthConstraint: NSLayoutConstraint? + private var currentMessage: ConsultantMessage? + private var currentReservesAvatarSpace = false + private var currentShowsAuthor = false + private var currentAuthorNameOverride: String? + private var currentTheme = ConsultantTheme.defaultTheme + private var avatarImageLoadTask: ImageLoadTask? + private var currentAvatarURL: URL? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private var quoteHighlightView: UIView? + + /// Limits context menu animations to the message bubble, excluding the avatar and row margins. + package func contextMenuPreview() -> UITargetedPreview { + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear + return UITargetedPreview(view: bubbleContainerView, parameters: parameters) + } + + /// Briefly highlights the full message row, including the avatar and surrounding space. + package func highlightQuotedMessage() { + quoteHighlightView?.removeFromSuperview() + let overlay = UIView(frame: contentView.bounds) + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + overlay.backgroundColor = currentTheme.brandColor.withAlphaComponent(0.2) + overlay.isUserInteractionEnabled = false + overlay.accessibilityIdentifier = "quoteNavigationHighlightView" + contentView.addSubview(overlay) + quoteHighlightView = overlay + UIView.animate(withDuration: 1.5, delay: 0.2, options: [.allowUserInteraction]) { + overlay.alpha = 0 + } completion: { _ in overlay.removeFromSuperview() } + } + + /// Resets all bubble views, avatar loading, cached message state, and width constraints before reuse. + package override func prepareForReuse() { + super.prepareForReuse() + quoteHighlightView?.layer.removeAllAnimations() + quoteHighlightView?.removeFromSuperview() + quoteHighlightView = nil + removeActiveBubble() + textMessageBubbleView.prepareForReuse() + imageMessageBubbleView.prepareForReuse() + fileMessageBubbleView.prepareForReuse() + quoteMessageBubbleView.prepareForReuse() + productMessageBubbleView.prepareForReuse() + orderMessageBubbleView.prepareForReuse() + contactFormMessageBubbleView.prepareForReuse() + avatarImageLoadTask?.cancel() + avatarImageLoadTask = nil + currentAvatarURL = nil + avatarImageView.image = nil + avatarImageView.isHidden = true + avatarImageView.contentMode = .scaleAspectFill + botAvatarImageView.isHidden = true + avatarLabel.text = nil + avatarLabel.isHidden = false + avatarView.isHidden = false + textMessageBubbleView.isHidden = true + imageMessageBubbleView.isHidden = true + fileMessageBubbleView.isHidden = true + quoteMessageBubbleView.isHidden = true + productMessageBubbleView.isHidden = true + orderMessageBubbleView.isHidden = true + contactFormMessageBubbleView.isHidden = true + dateSeparatorLabel.text = nil + dateSeparatorLabel.isHidden = true + bubbleTopConstraint?.constant = 4 + currentMessage = nil + currentReservesAvatarSpace = false + currentShowsAuthor = false + currentAuthorNameOverride = nil + currentTheme = .defaultTheme + bubbleWidthConstraint?.constant = 1 + } + + package override func layoutSubviews() { + super.layoutSubviews() + + if let currentMessage { + updateBubbleWidth( + for: currentMessage, + reservesAvatarSpace: currentReservesAvatarSpace + ) + } + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + contentView.backgroundColor = .clear + + contentView.addSubview(dateSeparatorLabel) + contentView.addSubview(avatarView) + contentView.addSubview(bubbleContainerView) + + avatarView.addSubview(avatarImageView) + avatarView.addSubview(botAvatarImageView) + avatarView.addSubview(avatarLabel) + } + + private func setupConstraints() { + bubbleTopConstraint = bubbleContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4) + avatarWidthConstraint = avatarView.widthAnchor.constraint(equalToConstant: 36) + bubbleIncomingLeadingConstraint = bubbleContainerView.leadingAnchor.constraint( + equalTo: avatarView.trailingAnchor, + constant: 8 + ) + bubbleIncomingNoAvatarLeadingConstraint = bubbleContainerView.leadingAnchor.constraint( + equalTo: contentView.leadingAnchor, + constant: 16 + ) + bubbleOutgoingTrailingConstraint = bubbleContainerView.trailingAnchor.constraint( + equalTo: contentView.trailingAnchor, + constant: -12 + ) + bubbleIncomingTrailingConstraint = bubbleContainerView.trailingAnchor.constraint( + lessThanOrEqualTo: contentView.trailingAnchor, + constant: -16 + ) + bubbleOutgoingLeadingConstraint = bubbleContainerView.leadingAnchor.constraint( + greaterThanOrEqualTo: contentView.leadingAnchor, + constant: 16 + ) + bubbleCenteredXConstraint = bubbleContainerView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + bubbleWidthConstraint = bubbleContainerView.widthAnchor.constraint(equalToConstant: 1) + let bubbleBottomConstraint = bubbleContainerView.bottomAnchor.constraint( + equalTo: contentView.bottomAnchor, + constant: -4 + ) + // A reused cell can retain its previous height until the collection applies new layout attributes. + // Let this edge yield temporarily instead of breaking image and quote content constraints. + bubbleBottomConstraint.priority = UILayoutPriority(999) + + NSLayoutConstraint.activate([ + avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + avatarView.bottomAnchor.constraint(equalTo: bubbleContainerView.bottomAnchor), + avatarWidthConstraint!, + avatarView.heightAnchor.constraint(equalToConstant: 36), + + avatarImageView.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarImageView.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarImageView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarImageView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + + botAvatarImageView.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), + botAvatarImageView.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), + botAvatarImageView.widthAnchor.constraint(equalToConstant: 24), + botAvatarImageView.heightAnchor.constraint(equalToConstant: 24), + + avatarLabel.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), + avatarLabel.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), + + dateSeparatorLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + dateSeparatorLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + dateSeparatorLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + dateSeparatorLabel.heightAnchor.constraint(equalToConstant: 16), + bubbleTopConstraint!, + bubbleBottomConstraint, + bubbleWidthConstraint! + ]) + } + + /// Applies message content, avatar visibility, author visibility, theme, manager identity, and interaction callbacks. + package func configure( + message: ConsultantMessage, + dateSeparatorText: String? = nil, + showsAvatar: Bool? = nil, + showsAuthor: Bool = true, + roundsAllCorners: Bool = false, + theme: ConsultantTheme = .defaultTheme, + managerIdentity: ManagerIdentity? = nil, + imageWorkers: [String] = [], + loadingFilePreviewIDs: Set = [], + loadingImagePreviewIDs: Set = [], + isContactFormSubmitting: Bool = false, + onImagePreview: (([ImagePreviewItem], Int) -> Void)? = nil, + onFilePreview: (([FilePreviewItem], Int) -> Void)? = nil, + onContactFormSubmit: ((ContactFormSubmitData) -> Void)? = nil, + onPersonalDataLinkTap: ((URL) -> Void)? = nil, + personalDataLink: URL? = nil, + onLinkTap: ((URL) -> Void)? = nil, + onQuoteTap: ((String) -> Void)? = nil + ) { + currentMessage = message + currentTheme = theme + dateSeparatorLabel.text = dateSeparatorText + dateSeparatorLabel.isHidden = dateSeparatorText == nil + dateSeparatorLabel.textColor = theme.isDarkTheme + ? theme.messageDateColor + : UIColor(red: 99 / 255, green: 111 / 255, blue: 127 / 255, alpha: 0.74) + bubbleTopConstraint?.constant = 4 + (dateSeparatorText == nil ? 0 : Self.dateSeparatorHeight) + + let bubbleKind = Self.bubbleKind(for: message) + let isContactFormMessage = bubbleKind == .contactForm + let isOutgoing = message.fromMe == true + let resolvedShowsAvatar = showsAvatar ?? (message.fromMe != true) + let shouldShowAvatar = !isOutgoing && resolvedShowsAvatar && !isContactFormMessage + let shouldShowAuthor = showsAuthor && !isContactFormMessage + let reservesAvatarSpace = !isOutgoing && !isContactFormMessage + let canUseManagerIdentity = !isOutgoing && !isContactFormMessage + let messageManagerIdentity = canUseManagerIdentity ? managerIdentity : nil + let authorNameOverride = messageManagerIdentity?.displayName + currentReservesAvatarSpace = reservesAvatarSpace + currentShowsAuthor = shouldShowAuthor + currentAuthorNameOverride = authorNameOverride + + configureAvatar( + message: message, + isHidden: !shouldShowAvatar, + reservesSpace: reservesAvatarSpace, + managerIdentity: messageManagerIdentity + ) + configurePosition( + isOutgoing: isOutgoing, + reservesAvatarSpace: reservesAvatarSpace, + isContactFormMessage: isContactFormMessage + ) + + textMessageBubbleView.isHidden = bubbleKind != .text + imageMessageBubbleView.isHidden = bubbleKind != .image + fileMessageBubbleView.isHidden = bubbleKind != .file + quoteMessageBubbleView.isHidden = bubbleKind != .quote + productMessageBubbleView.isHidden = bubbleKind != .product + orderMessageBubbleView.isHidden = bubbleKind != .order + contactFormMessageBubbleView.isHidden = bubbleKind != .contactForm + + switch bubbleKind { + case .image: + imageMessageBubbleView.onImagePreview = onImagePreview + imageMessageBubbleView.configure( + message: message, + showsAuthor: shouldShowAuthor, + roundsAllCorners: roundsAllCorners, + theme: theme, + authorNameOverride: authorNameOverride, + imageWorkers: imageWorkers, + loadingImagePreviewIDs: loadingImagePreviewIDs + ) + case .file: + fileMessageBubbleView.onFilePreview = onFilePreview + fileMessageBubbleView.configure( + message: message, + showsAuthor: shouldShowAuthor, + roundsAllCorners: roundsAllCorners, + theme: theme, + authorNameOverride: authorNameOverride, + loadingFilePreviewIDs: loadingFilePreviewIDs + ) + case .quote: + quoteMessageBubbleView.onQuoteTap = { + guard let id = message.quoteID?.value else { return } + onQuoteTap?(id) + } + quoteMessageBubbleView.onLinkTap = onLinkTap + quoteMessageBubbleView.configure( + message: message, + showsAuthor: shouldShowAuthor, + roundsAllCorners: roundsAllCorners, + theme: theme, + authorNameOverride: authorNameOverride + ) + case .product: + productMessageBubbleView.configure( + message: message, + showsAuthor: shouldShowAuthor, + roundsAllCorners: roundsAllCorners, + theme: theme, + authorNameOverride: authorNameOverride, + imageWorkers: imageWorkers + ) + case .order: + orderMessageBubbleView.configure( + message: message, + showsAuthor: shouldShowAuthor, + roundsAllCorners: roundsAllCorners, + theme: theme, + authorNameOverride: authorNameOverride + ) + case .contactForm: + contactFormMessageBubbleView.onSubmit = onContactFormSubmit + contactFormMessageBubbleView.onPersonalDataLinkTap = onPersonalDataLinkTap + contactFormMessageBubbleView.configure( + message: message, + roundsAllCorners: roundsAllCorners, + isSubmitting: isContactFormSubmitting, + theme: theme, + personalDataLink: personalDataLink + ) + case .text: + textMessageBubbleView.onLinkTap = onLinkTap + textMessageBubbleView.configure( + message: message, + showsAuthor: shouldShowAuthor, + roundsAllCorners: roundsAllCorners, + theme: theme, + authorNameOverride: authorNameOverride + ) + } + + updateBubbleWidth( + for: message, + reservesAvatarSpace: reservesAvatarSpace + ) + showBubble(for: bubbleKind) + } + + private func removeActiveBubble() { + NSLayoutConstraint.deactivate(activeBubbleConstraints) + activeBubbleConstraints = [] + activeBubbleView?.removeFromSuperview() + activeBubbleView = nil + } + + private func showBubble(for kind: MessageBubbleKind) { + let bubble: UIView + switch kind { + case .text: bubble = textMessageBubbleView + case .image: bubble = imageMessageBubbleView + case .file: bubble = fileMessageBubbleView + case .quote: bubble = quoteMessageBubbleView + case .product: bubble = productMessageBubbleView + case .order: bubble = orderMessageBubbleView + case .contactForm: bubble = contactFormMessageBubbleView + } + guard activeBubbleView !== bubble else { return } + + // Hidden views still participate in Auto Layout. Only the current message + // may constrain the container's size, especially after cell reuse. + removeActiveBubble() + bubbleContainerView.addSubview(bubble) + activeBubbleView = bubble + activeBubbleConstraints = [ + bubble.topAnchor.constraint(equalTo: bubbleContainerView.topAnchor), + bubble.leadingAnchor.constraint(equalTo: bubbleContainerView.leadingAnchor), + bubble.trailingAnchor.constraint(equalTo: bubbleContainerView.trailingAnchor), + bubble.bottomAnchor.constraint(equalTo: bubbleContainerView.bottomAnchor) + ] + NSLayoutConstraint.activate(activeBubbleConstraints) + } + + /// Updates the visible contact form submitting state without rebuilding the whole message cell. + package func setContactFormSubmitting(_ isSubmitting: Bool) { + guard currentMessage?.contactForm != nil else { + return + } + contactFormMessageBubbleView.setSubmitting(isSubmitting) + } + + /// Refreshes delivery metadata for the bubble type represented by the provided message. + package func updateDeliveryStatus( + message: ConsultantMessage, + theme: ConsultantTheme? = nil + ) { + currentMessage = message + let theme = theme ?? currentTheme + + switch Self.bubbleKind(for: message) { + case .image: + imageMessageBubbleView.updateDeliveryStatus(message: message, theme: theme) + case .file: + fileMessageBubbleView.updateDeliveryStatus(message: message, theme: theme) + case .quote: + quoteMessageBubbleView.updateDeliveryStatus(message: message, theme: theme) + case .product: + productMessageBubbleView.updateDeliveryStatus(message: message, theme: theme) + case .order: + orderMessageBubbleView.updateDeliveryStatus(message: message, theme: theme) + case .text: + textMessageBubbleView.updateDeliveryStatus(message: message, theme: theme) + case .contactForm: + break + } + } + + private func configureAvatar( + message: ConsultantMessage, + isHidden: Bool, + reservesSpace: Bool, + managerIdentity: ManagerIdentity? + ) { + avatarImageLoadTask?.cancel() + avatarImageLoadTask = nil + currentAvatarURL = nil + avatarImageView.image = nil + avatarImageView.isHidden = true + avatarImageView.contentMode = .scaleAspectFill + botAvatarImageView.isHidden = true + avatarLabel.isHidden = false + avatarView.isHidden = isHidden + avatarWidthConstraint?.constant = reservesSpace ? 36 : 0 + avatarLabel.text = initials(from: managerIdentity?.displayName) ?? initials(from: message.user) + avatarView.backgroundColor = currentTheme.consultantBubbleColor + avatarLabel.textColor = currentTheme.primaryTextColor + botAvatarImageView.tintColor = currentTheme.isDarkTheme ? .white : .black + + if message.isVirtualSupportMessage, + managerIdentity?.logoURL == nil, + botAvatarImageView.image != nil { + botAvatarImageView.isHidden = false + avatarLabel.isHidden = true + return + } + + guard !isHidden, let url = avatarURL(for: message, managerIdentity: managerIdentity) else { + return + } + + currentAvatarURL = url + avatarImageLoadTask = ImageLoader.shared.loadImage(from: url) { [weak self] image in + guard self?.currentAvatarURL == url else { + return + } + + self?.avatarImageView.image = image + self?.avatarImageView.isHidden = image == nil + self?.avatarLabel.isHidden = image != nil + } + } + + private func avatarURL( + for message: ConsultantMessage, + managerIdentity: ManagerIdentity? + ) -> URL? { + let fallbackAvatarURL = message.user?.avatar? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + .flatMap(URL.init(string:)) + + return managerIdentity?.logoURL ?? fallbackAvatarURL + } + + private func configurePosition( + isOutgoing: Bool, + reservesAvatarSpace: Bool, + isContactFormMessage: Bool + ) { + bubbleIncomingLeadingConstraint?.isActive = false + bubbleIncomingNoAvatarLeadingConstraint?.isActive = false + bubbleOutgoingTrailingConstraint?.isActive = false + bubbleIncomingTrailingConstraint?.isActive = false + bubbleOutgoingLeadingConstraint?.isActive = false + bubbleCenteredXConstraint?.isActive = false + + if isContactFormMessage { + bubbleCenteredXConstraint?.isActive = true + return + } + + if isOutgoing { + bubbleOutgoingTrailingConstraint?.isActive = true + bubbleOutgoingLeadingConstraint?.isActive = true + } else { + if reservesAvatarSpace { + bubbleIncomingLeadingConstraint?.isActive = true + } else { + bubbleIncomingNoAvatarLeadingConstraint?.isActive = true + } + bubbleIncomingTrailingConstraint?.isActive = true + } + } + + private func updateBubbleWidth( + for message: ConsultantMessage, + reservesAvatarSpace: Bool + ) { + let collectionWidth = max(contentView.bounds.width, bounds.width) + + guard collectionWidth > 0 else { + return + } + + bubbleWidthConstraint?.constant = Self.preferredBubbleWidth( + for: message, + collectionWidth: collectionWidth, + reservesAvatarSpace: reservesAvatarSpace, + showsAuthor: currentShowsAuthor, + authorNameOverride: currentAuthorNameOverride + ) + } + + private func initials(from user: MessageUser?) -> String { + let initials = [user?.firstName, user?.lastName] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).first } + .map(String.init) + .joined() + .uppercased() + return initials.isEmpty + ? ConsultantLocalization.messageAuthorConsultantInitial.localized + : initials + } + + private func initials(from displayName: String?) -> String? { + guard let displayName else { + return nil + } + + let parts = displayName + .split { $0.isWhitespace || $0.isNewline } + .prefix(2) + let initials = parts + .compactMap { $0.first.map(String.init) } + .joined() + .uppercased() + + return initials.isEmpty ? nil : initials + } +} + +package extension MessageCollectionViewCell { + + private enum MessageBubbleKind { + case contactForm + case image + case file + case product + case order + case quote + case text + } + + private static func bubbleKind(for message: ConsultantMessage) -> MessageBubbleKind { + switch message.messageType { + case .contactForm where message.contactForm != nil: + return .contactForm + case .image: + return .image + case .file: + return .file + case .product where message.product != nil: + return .product + case .order where message.order != nil: + return .order + default: + break + } + + if message.contactForm != nil { + return .contactForm + } + + if message.quotePreview != nil { + return .quote + } + + return .text + } + + /// Calculates the bubble width for the message type, avatar reservation, and author visibility. + static func preferredBubbleWidth( + for message: ConsultantMessage, + collectionWidth: CGFloat, + reservesAvatarSpace: Bool? = nil, + showsAuthor: Bool = true, + authorNameOverride: String? = nil + ) -> CGFloat { + let bubbleKind = bubbleKind(for: message) + let isOutgoing = message.fromMe == true + let resolvedReservesAvatarSpace = reservesAvatarSpace ?? (message.fromMe != true) + let shouldReserveAvatarSpace = !isOutgoing && resolvedReservesAvatarSpace && bubbleKind != .contactForm + let maxBubbleWidth = maximumBubbleWidth( + collectionWidth: collectionWidth, + reservesAvatarSpace: shouldReserveAvatarSpace + ) + + switch bubbleKind { + case .contactForm: + return ContactFormMessageLayout.preferredBubbleWidth( + maxBubbleWidth: maxBubbleWidth + ) + case .image: + let imageBubbleWidth = ImageMessageLayout.preferredBubbleWidth( + maxBubbleWidth: maxBubbleWidth, + sourceSizes: message.imageDisplayItems.map(\.sourceImageSize) + ) + let authorBubbleWidth = imageAuthorBubbleWidth( + for: message, + maxBubbleWidth: maxBubbleWidth, + showsAuthor: showsAuthor, + authorNameOverride: isOutgoing ? nil : authorNameOverride + ) + + return max(imageBubbleWidth, authorBubbleWidth) + case .file: + return fileBubbleWidth( + for: message, + maxBubbleWidth: maxBubbleWidth, + showsAuthor: showsAuthor, + authorNameOverride: isOutgoing ? nil : authorNameOverride + ) + case .product: + return productBubbleWidth( + for: message, + maxBubbleWidth: maxBubbleWidth, + showsAuthor: showsAuthor, + authorNameOverride: isOutgoing ? nil : authorNameOverride + ) + case .order: + return orderBubbleWidth( + for: message, + maxBubbleWidth: maxBubbleWidth, + showsAuthor: showsAuthor, + authorNameOverride: isOutgoing ? nil : authorNameOverride + ) + case .quote: + return quoteBubbleWidth( + for: message, + maxBubbleWidth: maxBubbleWidth, + showsAuthor: showsAuthor, + authorNameOverride: isOutgoing ? nil : authorNameOverride + ) + case .text: + return textBubbleWidth( + for: message, + maxBubbleWidth: maxBubbleWidth, + showsAuthor: showsAuthor, + authorNameOverride: isOutgoing ? nil : authorNameOverride + ) + } + } + + private static func maximumBubbleWidth( + collectionWidth: CGFloat, + reservesAvatarSpace: Bool + ) -> CGFloat { + guard collectionWidth > 0 else { + return 1 + } + + let layoutMaxWidth = collectionWidth * MessageLayout.bubbleMaxWidthMultiplier + let horizontalInsets: CGFloat = 32 + let avatarWidth: CGFloat = reservesAvatarSpace ? 36 : 0 + let avatarSpacing: CGFloat = reservesAvatarSpace ? 8 : 0 + let availableWidth = collectionWidth - horizontalInsets - avatarWidth - avatarSpacing + + return max(1, min(layoutMaxWidth, availableWidth)) + } + + private static func textBubbleWidth( + for message: ConsultantMessage, + maxBubbleWidth: CGFloat, + showsAuthor: Bool, + authorNameOverride: String? + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let isOutgoing = message.fromMe == true + let messageText = message.content?.isEmpty == false + ? message.content + : message.messageType.map { "[\($0.rawValue)]" } + let messageWidth = width( + for: messageText, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.messageFont + ) + let authorName = authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + let authorWidth = !showsAuthor + ? 0 + : width( + for: authorName, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.authorFont + ) + let metadataWidth = MetadataView.preferredWidth( + for: message, + maxWidth: maxContentWidth + ) + let contentWidth = min(maxContentWidth, max(messageWidth, authorWidth, metadataWidth)) + + return ceil(max(52, contentWidth + MessageLayout.bubbleHorizontalInset * 2)) + } + + private static func imageAuthorBubbleWidth( + for message: ConsultantMessage, + maxBubbleWidth: CGFloat, + showsAuthor: Bool, + authorNameOverride: String? + ) -> CGFloat { + guard showsAuthor else { + return 0 + } + + let isOutgoing = message.fromMe == true + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.compactSpacing * 2) + let authorName = authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + let authorWidth = width( + for: authorName, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.authorFont + ) + + return ceil(authorWidth + MessageLayout.compactSpacing * 2) + } + + private static func fileBubbleWidth( + for message: ConsultantMessage, + maxBubbleWidth: CGFloat, + showsAuthor: Bool, + authorNameOverride: String? + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let isOutgoing = message.fromMe == true + let titleWidths = message.fileDisplayItems.map { + width( + for: $0.displayName, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.semiboldMediumFont + ) + } + let sizeWidths = message.fileDisplayItems.map { + width( + for: $0.sizeLabel, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.regularSmallFont + ) + } + let authorName = authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + let authorWidth = !showsAuthor + ? 0 + : width( + for: authorName, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.authorFont + ) + let metadataWidth = MetadataView.preferredWidth( + for: message, + maxWidth: maxContentWidth + ) + + return FileMessageLayout.preferredBubbleWidth( + maxBubbleWidth: maxBubbleWidth, + fileNameWidths: titleWidths, + fileSizeWidths: sizeWidths, + authorWidth: authorWidth, + metadataWidth: metadataWidth + ) + } + + private static func quoteBubbleWidth( + for message: ConsultantMessage, + maxBubbleWidth: CGFloat, + showsAuthor: Bool, + authorNameOverride: String? + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let isOutgoing = message.fromMe == true + let bodyWidth = width( + for: message.content, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.messageFont + ) + let authorName = authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + let authorWidth = !showsAuthor + ? 0 + : width( + for: authorName, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.authorFont + ) + let quoteWidth = quotePreviewWidth( + for: message.quotePreview, + maxWidth: maxContentWidth + ) + let metadataWidth = MetadataView.preferredWidth( + for: message, + maxWidth: maxContentWidth + ) + + return QuoteMessageLayout.preferredBubbleWidth( + maxBubbleWidth: maxBubbleWidth, + authorWidth: authorWidth, + bodyWidth: bodyWidth, + quoteWidth: quoteWidth, + metadataWidth: metadataWidth + ) + } + + private static func productBubbleWidth( + for message: ConsultantMessage, + maxBubbleWidth: CGFloat, + showsAuthor: Bool, + authorNameOverride: String? + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let isOutgoing = message.fromMe == true + let extraTextInset = ProductMessageLayout.additionalTextTrailingInset(isOutgoing: isOutgoing) + let maxTextWidth = max(1, maxContentWidth - extraTextInset) + let titleWidth = width( + for: message.productDisplayName, + maxWidth: maxTextWidth, + font: MessageBubbleStyle.messageFont + ) + let priceWidth = width( + for: message.productPriceLabel, + maxWidth: maxTextWidth, + font: MessageBubbleStyle.semiboldCompactFont + ) + let quantityWidth = width( + for: message.productQuantityLabel, + maxWidth: maxTextWidth, + font: MessageBubbleStyle.regularCompactFont + ) + let authorName = authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + let authorWidth = !showsAuthor + ? 0 + : width( + for: authorName, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.authorFont + ) + let metadataWidth = MetadataView.preferredWidth( + for: message, + maxWidth: maxContentWidth + ) + + return ProductMessageLayout.preferredBubbleWidth( + maxBubbleWidth: maxBubbleWidth, + titleWidth: titleWidth, + priceWidth: priceWidth, + quantityWidth: quantityWidth, + authorWidth: authorWidth, + metadataWidth: metadataWidth, + minimumContentWidth: isOutgoing ? ProductMessageLayout.defaultContentWidth : 0, + additionalTextTrailingInset: extraTextInset + ) + } + + private static func orderBubbleWidth( + for message: ConsultantMessage, + maxBubbleWidth: CGFloat, + showsAuthor: Bool, + authorNameOverride: String? + ) -> CGFloat { + let maxContentWidth = max(1, maxBubbleWidth - MessageLayout.bubbleHorizontalInset * 2) + let isOutgoing = message.fromMe == true + let orderNumberWidth = width( + for: message.orderNumberLabel, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.semiboldMediumFont + ) + let itemWidth = message.orderDisplayItems.reduce(CGFloat(0)) { result, item in + let titleWidth = width( + for: "\(item.numberLabel) \(item.name)", + maxWidth: maxContentWidth, + font: MessageBubbleStyle.messageFont + ) + let detailsWidth = width( + for: item.details, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.regularCompactFont + ) + + return max(result, titleWidth, detailsWidth) + } + let fieldWidth = message.orderDisplayFields.reduce(CGFloat(0)) { result, field in + let titleWidth = width( + for: field.title, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.semiboldMediumFont + ) + let valueWidth = width( + for: field.value, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.regularCompactFont + ) + + return max(result, titleWidth, valueWidth) + } + let authorName = authorNameOverride + ?? message.user?.displayName(isOutgoing: isOutgoing) + ?? MessageUser.fallbackDisplayName(isOutgoing: isOutgoing) + let authorWidth = !showsAuthor + ? 0 + : width( + for: authorName, + maxWidth: maxContentWidth, + font: MessageBubbleStyle.authorFont + ) + let metadataWidth = MetadataView.preferredWidth( + for: message, + maxWidth: maxContentWidth + ) + + return OrderMessageLayout.preferredBubbleWidth( + maxBubbleWidth: maxBubbleWidth, + orderContentWidth: max(orderNumberWidth, itemWidth, fieldWidth), + authorWidth: authorWidth, + metadataWidth: metadataWidth, + minimumContentWidth: isOutgoing ? OrderMessageLayout.defaultContentWidth : 0 + ) + } + + private static func quotePreviewWidth( + for preview: MessageQuotePreview?, + maxWidth: CGFloat + ) -> CGFloat { + guard let preview else { + return 0 + } + + let leadingWidth: CGFloat + + switch preview { + case .text(let text): + leadingWidth = QuoteMessageLayout.quoteBarWidth + + QuoteMessageLayout.quoteBarContentSpacing + let textWidth = width( + for: text, + maxWidth: max(1, maxWidth - leadingWidth), + font: MessageBubbleStyle.regularSmallFont + ) + + return min(maxWidth, ceil(leadingWidth + textWidth)) + case .attachments(let items, let isImage, let text, let author): + let leadingWidth = QuoteMessageLayout.quoteBarWidth + QuoteMessageLayout.quoteBarContentSpacing + let thumbnailWidth = QuoteMessageLayout.thumbnailSize + QuoteMessageLayout.thumbnailSpacing + let labelMaxWidth = max(1, maxWidth - leadingWidth - thumbnailWidth) + let authorWidth = width(for: author, maxWidth: labelMaxWidth, font: MessageBubbleStyle.authorFont) + let titleWidth = items.map { item in + width( + for: isImage ? ConsultantLocalization.messageQuotePhoto.localized + : item.caption ?? ConsultantLocalization.commonFile.localized, + maxWidth: labelMaxWidth, + font: MessageBubbleStyle.regularSmallFont + ) + }.max() ?? 0 + let attachmentWidth = thumbnailWidth + max(authorWidth, titleWidth) + let textWidth = width( + for: text, + maxWidth: max(1, maxWidth - leadingWidth), + font: MessageBubbleStyle.regularSmallFont + ) + return min(maxWidth, ceil(leadingWidth + max(attachmentWidth, textWidth))) + } + } + + private static func width( + for text: String?, + maxWidth: CGFloat, + font: UIFont + ) -> CGFloat { + guard let text, !text.isEmpty else { + return 0 + } + + return ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, maxWidth), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil + ).width + ) + } + + /// Calculates the full cell height for the message type and collection width. + static func preferredHeight( + for message: ConsultantMessage, + collectionWidth: CGFloat, + showsAuthor: Bool = true + ) -> CGFloat { + switch bubbleKind(for: message) { + case .contactForm: + return ContactFormMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth + ) + case .image: + return ImageMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth, + showsAuthor: showsAuthor + ) + case .file: + return FileMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth, + showsAuthor: showsAuthor + ) + case .product: + return ProductMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth, + showsAuthor: showsAuthor + ) + case .order: + return OrderMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth, + showsAuthor: showsAuthor + ) + case .quote: + return QuoteMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth, + showsAuthor: showsAuthor + ) + case .text: + return TextMessageBubbleView.preferredHeight( + for: message, + collectionWidth: collectionWidth, + showsAuthor: showsAuthor + ) + } + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/ActionImage.swift b/Sources/OnlineConsultant/UI/Helpers/ActionImage.swift new file mode 100644 index 0000000..c821652 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/ActionImage.swift @@ -0,0 +1,100 @@ +import CoreGraphics +import Foundation + +/// Preinstalled action image shown in contact-form messages. +package enum ActionImage { + + /// Greeting illustration bundled with the SDK. + case hello + + /// Gift illustration bundled with the SDK. + case present + + /// Fire illustration bundled with the SDK. + case fire + + /// Confetti illustration bundled with the SDK. + case confetti + + /// Creates a bundled action image from the backend preinstalled image identifier. + package init?(preinstalledID value: String?) { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return nil + } + + switch value { + case "1": + self = .hello + case "2": + self = .present + case "3": + self = .fire + case "4": + self = .confetti + default: + return nil + } + } + + /// Backend identifier for this bundled action image. + package var preinstalledID: String { + switch self { + case .hello: + return "1" + case .present: + return "2" + case .fire: + return "3" + case .confetti: + return "4" + } + } + + /// URL of the bundled PNG resource for this action image. + package var preinstalledURL: URL? { + imageResource.fileURL + } + + /// Intrinsic bitmap size used by the frontend for this bundled action image. + package var preinstalledSize: CGSize { + switch self { + case .hello, .present, .fire: + return CGSize(width: 323, height: 256) + case .confetti: + return CGSize(width: 324, height: 256) + } + } + + /// Returns a valid remote, data, or file URL from backend/custom image value. + package static func remoteURL(from value: String?) -> URL? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + let normalizedValue = value.hasPrefix("//") ? "https:\(value)" : value + + guard + let url = URL(string: normalizedValue), + let scheme = url.scheme?.lowercased(), + ["http", "https", "data", "file"].contains(scheme) + else { + return nil + } + + return url + } + + /// Image resource for this bundled action image. + private var imageResource: ConsultantImageResource { + switch self { + case .hello: + return .actionHello + case .present: + return .actionPresent + case .fire: + return .actionFire + case .confetti: + return .actionConfetti + } + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/DateParser.swift b/Sources/OnlineConsultant/UI/Helpers/DateParser.swift new file mode 100644 index 0000000..5c428f3 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/DateParser.swift @@ -0,0 +1,56 @@ +// +// OnlineConsultantDateParser.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 23.07.2026. +// + +import Foundation + +package enum DateParser { + + /// Parses backend timestamps that may use several RFC3339/ISO8601 precisions. + package static func date(from value: String) -> Date? { + isoDateFormatter.date(from: value) + ?? fallbackISODateFormatter.date(from: value) + ?? rfc3339MicrosecondsFormatter.date(from: value) + ?? rfc3339MillisecondsFormatter.date(from: value) + } + + package static func string(from value: Date) -> String { + isoDateFormatter.string(from: value) + } + + /// ISO8601 parser for timestamps with fractional seconds. + private static let isoDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + /// ISO8601 parser for timestamps without fractional seconds. + private static let fallbackISODateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + /// RFC3339 parser for Go/backend timestamps with microsecond precision. + private static let rfc3339MicrosecondsFormatter: DateFormatter = { + makeInputDateFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX") + }() + + /// RFC3339 parser for timestamps with millisecond precision. + private static let rfc3339MillisecondsFormatter: DateFormatter = { + makeInputDateFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX") + }() + + /// Creates a stable POSIX date formatter for backend timestamp parsing. + private static func makeInputDateFormatter(format: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = format + return formatter + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/FilePreviewResolver.swift b/Sources/OnlineConsultant/UI/Helpers/FilePreviewResolver.swift new file mode 100644 index 0000000..791992f --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/FilePreviewResolver.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Resolves file preview candidates into a set of local files that Quick Look can present. +package enum FilePreviewResolver { + + /// Returns the item at `startIndex`, clamping the index to the available item range. + package static func selectedItem( + from items: [FilePreviewItem], + startIndex: Int + ) -> FilePreviewItem? { + guard !items.isEmpty else { + return nil + } + + return items[min(max(startIndex, 0), items.count - 1)] + } + + /// Trims an optional backend file identifier and returns `nil` when it is missing or empty. + package static func normalizedFileID(_ fileID: String?) -> String? { + let normalizedFileID = fileID?.trimmingCharacters(in: .whitespacesAndNewlines) + return normalizedFileID?.isEmpty == false ? normalizedFileID : nil + } + + /// Filters the provided items to locally available files and maps the selected item to its new index. + package static func localPreviewItems( + from items: [FilePreviewItem], + startIndex: Int + ) -> (items: [FilePreviewItem], startIndex: Int)? { + guard !items.isEmpty else { + return nil + } + + let selectedIndex = min(max(startIndex, 0), items.count - 1) + let selectedItem = items[selectedIndex] + guard canPreviewLocalFile(selectedItem.url) else { + return nil + } + + let localItems = items.filter { canPreviewLocalFile($0.url) } + guard + let localStartIndex = localItems.firstIndex(where: { + $0.id == selectedItem.id + && $0.url == selectedItem.url + }) + else { + return nil + } + + return (localItems, localStartIndex) + } + + /// Returns `true` when the URL points to an existing local file that can be passed to Quick Look. + private static func canPreviewLocalFile(_ url: URL) -> Bool { + url.isFileURL && FileManager.default.fileExists(atPath: url.path) + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/ImageLoader.swift b/Sources/OnlineConsultant/UI/Helpers/ImageLoader.swift new file mode 100644 index 0000000..d4c4f84 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/ImageLoader.swift @@ -0,0 +1,118 @@ +import Core +import UIKit + +/// Cancellable task returned by the SDK image loader. +package protocol ImageLoadTask { + + /// Cancels the underlying image loading operation. + func cancel() +} + +extension URLSessionDataTask: ImageLoadTask {} + +/// Loads and caches images used by message bubbles, avatars, and previews. +package final class ImageLoader { + + /// Shared image loader used by SDK UI components. + package static let shared = ImageLoader() + + private let session: URLSession + private let cache: NSCache + + /// Creates an image loader with injectable networking and cache dependencies for tests. + package init( + session: URLSession = NetworkSession.shared, + cache: NSCache = NSCache() + ) { + self.session = session + self.cache = cache + } + + /// Returns a cached image for the URL, if it has already been loaded. + package func cachedImage(for url: URL) -> UIImage? { + cache.object(forKey: url as NSURL) + } + + /// Loads an image from memory cache, data URL, file URL, or network URL. + /// + /// The completion is called synchronously for cache, data URL, and file URL hits. + /// Network results are delivered on the main queue. + @discardableResult + package func loadImage( + from url: URL, + completion: @escaping (UIImage?) -> Void + ) -> ImageLoadTask? { + if let cachedImage = cachedImage(for: url) { + completion(cachedImage) + return nil + } + + if let data = Self.imageData(from: url.absoluteString) { + let image = UIImage(data: data) + cache(image, for: url) + completion(image) + return nil + } + + if url.isFileURL { + let image = (try? Data(contentsOf: url)).flatMap(UIImage.init(data:)) + cache(image, for: url) + completion(image) + return nil + } + + let task = NetworkSession.dataTask( + with: URLRequest(url: url), in: session + ) { [weak self] data, _, _ in + guard + let data, + let image = UIImage(data: data) + else { + DispatchQueue.main.async { + completion(nil) + } + return + } + + self?.cache(image, for: url) + + DispatchQueue.main.async { + completion(image) + } + } + task.resume() + return task + } + + /// Stores an image in memory cache using an approximate RGBA bitmap cost. + private func cache(_ image: UIImage?, for url: URL) { + guard let image else { + return + } + + let pixelWidth = max(1, Int(image.size.width * image.scale)) + let pixelHeight = max(1, Int(image.size.height * image.scale)) + cache.setObject( + image, + forKey: url as NSURL, + cost: pixelWidth * pixelHeight * 4 + ) + } + + /// Decodes base64 data from a `data:image/...;base64,...` URL string. + private static func imageData(from value: String) -> Data? { + guard value.hasPrefix("data:image/") else { + return nil + } + + let parts = value.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false) + guard + parts.count == 2, + parts[0].contains(";base64") + else { + return nil + } + + return Data(base64Encoded: String(parts[1])) + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/ImagePickerMetadata.swift b/Sources/OnlineConsultant/UI/Helpers/ImagePickerMetadata.swift new file mode 100644 index 0000000..c1aa7a2 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/ImagePickerMetadata.swift @@ -0,0 +1,121 @@ +import Foundation +import UniformTypeIdentifiers + +/// Builds stable upload metadata for images selected from `PHPickerViewController`. +package enum ImagePickerMetadata { + + /// Media type identifier used when configuring camera capture for still images. + package static var imageMediaTypeIdentifier: String { + UTType.image.identifier + } + + /// File metadata needed to create and upload an input image attachment. + package struct Metadata { + /// Type identifier used to request binary data from `NSItemProvider`. + package let typeIdentifier: String + + /// File name sent to the upload endpoint. + package let fileName: String + + /// MIME type sent to the upload endpoint. + package let contentType: String + + package init( + typeIdentifier: String, + fileName: String, + contentType: String + ) { + self.typeIdentifier = typeIdentifier + self.fileName = fileName + self.contentType = contentType + } + } + + /// Creates upload metadata from the item provider exposed by the system photo picker. + package static func make(from itemProvider: NSItemProvider) -> Metadata { + let typeIdentifier = preferredImageTypeIdentifier(from: itemProvider) + return Metadata( + typeIdentifier: typeIdentifier, + fileName: imageFileName( + suggestedName: itemProvider.suggestedName, + typeIdentifier: typeIdentifier + ), + contentType: imageContentType(for: typeIdentifier) + ) + } + + private static let supportedTypeIdentifiers = [ + "public.jpeg", + "public.png", + "public.heic", + "public.heif", + "com.compuserve.gif", + "public.tiff", + "public.image" + ] + + /// Selects the most specific supported image type identifier available on the item provider. + private static func preferredImageTypeIdentifier(from itemProvider: NSItemProvider) -> String { + supportedTypeIdentifiers.first { itemProvider.hasItemConformingToTypeIdentifier($0) } + ?? "public.image" + } + + /// Builds a file name from the provider suggestion and appends an extension when it is missing. + private static func imageFileName( + suggestedName: String?, + typeIdentifier: String + ) -> String { + let fileExtension = imageFileExtension(for: typeIdentifier) + let fallbackName = "photo-\(UUID().uuidString)" + let trimmedSuggestedName = suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedBaseName: String + if let trimmedSuggestedName, !trimmedSuggestedName.isEmpty { + normalizedBaseName = trimmedSuggestedName + } else { + normalizedBaseName = fallbackName + } + let currentExtension = (normalizedBaseName as NSString).pathExtension + + guard currentExtension.isEmpty else { + return normalizedBaseName + } + + return "\(normalizedBaseName).\(fileExtension)" + } + + /// Maps a Uniform Type Identifier to the file extension used in the upload file name. + private static func imageFileExtension(for typeIdentifier: String) -> String { + switch typeIdentifier { + case "public.png": + return "png" + case "public.heic": + return "heic" + case "public.heif": + return "heif" + case "com.compuserve.gif": + return "gif" + case "public.tiff": + return "tiff" + default: + return "jpg" + } + } + + /// Maps a Uniform Type Identifier to the MIME type sent with the upload request. + private static func imageContentType(for typeIdentifier: String) -> String { + switch typeIdentifier { + case "public.png": + return "image/png" + case "public.heic": + return "image/heic" + case "public.heif": + return "image/heif" + case "com.compuserve.gif": + return "image/gif" + case "public.tiff": + return "image/tiff" + default: + return "image/jpeg" + } + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/InputBarAttachmentCoordinator.swift b/Sources/OnlineConsultant/UI/Helpers/InputBarAttachmentCoordinator.swift new file mode 100644 index 0000000..90c3514 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/InputBarAttachmentCoordinator.swift @@ -0,0 +1,558 @@ +import UIKit +import PhotosUI +import UniformTypeIdentifiers + +/// Coordinates the complete input bar attachment flow for `OnlineConsultantViewController`. +/// +/// The coordinator owns system picker presentation, file validation, upload tasks, retry payloads, +/// and removal of pending input attachments. It stays package-scoped because it depends on UIKit +/// presentation APIs and is wired by the ready-to-use view controller rather than by `ConsultantView`. +@MainActor +package final class InputBarAttachmentCoordinator: NSObject { + + /// View controller used to present action sheets and system pickers. + package weak var presentingViewController: UIViewController? + + /// Called when an attachment operation fails and the host should show a generic error. + package var onError: ((Error) -> Void)? + + /// Called when an attachment operation needs to show a title/message alert. + package var onAlert: ((String, String) -> Void)? + + private static let maxInputAttachmentCount = 10 + private static let maxImageAttachmentFileSize = 3 * 1024 * 1024 + private static let maxDocumentAttachmentFileSize = 5 * 1024 * 1024 + + private var controller: OnlineConsultantController? + private var uploadAttachmentTasks: [UUID: Task] = [:] + private var inputAttachmentUploadPayloads: [UUID: InputAttachmentUploadPayload] = [:] + + private var remainingInputAttachmentSlots: Int { + max(0, Self.maxInputAttachmentCount - (controller?.inputAttachments.count ?? 0)) + } + + private var hasFreeInputAttachmentSlot: Bool { + remainingInputAttachmentSlots > 0 + } + + deinit { + uploadAttachmentTasks.values.forEach { $0.cancel() } + } + + /// Updates the controller used for creating, uploading, retrying, and removing input attachments. + package func setController(_ controller: OnlineConsultantController?) { + self.controller = controller + } + + /// Cancels all active attachment upload tasks. + package func cancelTasks() { + uploadAttachmentTasks.values.forEach { $0.cancel() } + uploadAttachmentTasks.removeAll() + } + + /// Clears transient upload state when the bound consultant controller changes. + package func resetState() { + cancelTasks() + inputAttachmentUploadPayloads.removeAll() + } + + /// Cancels an upload, drops its retry payload, and removes the attachment from the controller. + package func removeInputAttachment(id: UUID) { + uploadAttachmentTasks[id]?.cancel() + uploadAttachmentTasks[id] = nil + inputAttachmentUploadPayloads[id] = nil + controller?.removeInputAttachment(id: id) + } + + /// Repeats upload for a failed input attachment when its original payload is still available. + package func retryInputAttachment(id: UUID) { + guard let payload = inputAttachmentUploadPayloads[id] else { + showAlert( + title: ConsultantLocalization.commonError.localized, + message: ConsultantLocalization.attachmentErrorRetryUploadFailed.localized + ) + return + } + + uploadInputAttachment(id: id, payload: payload) + } + + /// Drops retry payloads for attachments that are no longer present in the input bar. + package func pruneUploadPayloads(keeping attachments: [InputAttachment]) { + let attachmentIDs = Set(attachments.map(\.id)) + let payloadIDsToRemove = inputAttachmentUploadPayloads.keys.filter { + !attachmentIDs.contains($0) + } + removeUploadPayloads(for: payloadIDsToRemove) + } + + /// Drops retry payloads after the selected attachments are sent with a message. + package func clearUploadPayloads(for attachments: [InputAttachment]) { + removeUploadPayloads(for: attachments.map(\.id)) + } + + /// Presents the attachment source menu anchored to the input bar attachment button. + package func showAttachmentMenu(sourceView: UIView) { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction( + title: ConsultantLocalization.attachmentActionCamera.localized, + style: .default + ) { [weak self] _ in + self?.showCameraPicker() + }) + alert.addAction(UIAlertAction( + title: ConsultantLocalization.attachmentActionPhotoOrVideo.localized, + style: .default + ) { [weak self] _ in + self?.showPhotoPicker() + }) + alert.addAction(UIAlertAction( + title: ConsultantLocalization.commonFile.localized, + style: .default + ) { [weak self] _ in + self?.showDocumentPicker() + }) + alert.addAction(UIAlertAction( + title: ConsultantLocalization.attachmentActionCancel.localized, + style: .cancel + )) + + if presentingViewController?.traitCollection.userInterfaceIdiom == .pad { + alert.popoverPresentationController?.sourceView = sourceView + alert.popoverPresentationController?.sourceRect = sourceView.bounds + } + + presentingViewController?.present(alert, animated: true) + } + + /// Presents camera capture after checking attachment count, camera permission text, and availability. + private func showCameraPicker() { + guard hasFreeInputAttachmentSlot else { + showError(AttachmentPickerError.tooManyAttachments) + return + } + + let hasCameraUsageDescription = Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription") != nil + + guard hasCameraUsageDescription else { + showAlert( + title: ConsultantLocalization.commonUnavailable.localized, + message: ConsultantLocalization.attachmentErrorCameraMissingUsageDescription.localized + ) + return + } + + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + showAlert( + title: ConsultantLocalization.commonUnavailable.localized, + message: ConsultantLocalization.attachmentErrorCameraUnavailable.localized + ) + return + } + + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.mediaTypes = [ImagePickerMetadata.imageMediaTypeIdentifier] + picker.allowsEditing = false + picker.delegate = self + presentingViewController?.present(picker, animated: true) + } + + /// Presents the system document picker for file attachments. + private func showDocumentPicker() { + guard hasFreeInputAttachmentSlot else { + showError(AttachmentPickerError.tooManyAttachments) + return + } + + let picker = UIDocumentPickerViewController( + forOpeningContentTypes: [.item], + asCopy: true + ) + picker.allowsMultipleSelection = true + picker.delegate = self + presentingViewController?.present(picker, animated: true) + } + + /// Presents the system photo picker with selection limited by the remaining attachment slots. + private func showPhotoPicker() { + let remainingSlots = remainingInputAttachmentSlots + guard remainingSlots > 0 else { + showError(AttachmentPickerError.tooManyAttachments) + return + } + + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = remainingSlots + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + presentingViewController?.present(picker, animated: true) + } + + /// Validates picked image data and calls `onValidData` only when it can be uploaded. + private func handlePickedImageData( + _ data: Data?, + failAttachmentID: UUID?, + onValidData: (Data) -> Void + ) { + guard let data, !data.isEmpty else { + handlePickedImageError( + AttachmentPickerError.emptyImageData, + failAttachmentID: failAttachmentID + ) + return + } + + do { + try validateImageSize(data.count) + } catch { + handlePickedImageError(error, failAttachmentID: failAttachmentID) + return + } + + onValidData(data) + } + + /// Marks an already-created picker attachment as failed and reports the error to the host. + private func handlePickedImageError( + _ error: Error, + failAttachmentID: UUID? + ) { + if let failAttachmentID { + controller?.failInputAttachment( + id: failAttachmentID, + message: error.localizedDescription + ) + } + + showError(error) + } + + /// Uploads data for an image attachment that was already inserted by the photo picker flow. + private func uploadImageAttachment( + id: UUID, + data: Data, + contentType: String + ) { + guard let controller else { + showError(ConsultantControllerError.notConnected) + return + } + + guard controller.inputAttachments.contains(where: { $0.id == id }) else { + return + } + + let payload = InputAttachmentUploadPayload( + data: data, + contentType: contentType, + previewData: data + ) + + storeAndUploadInputAttachment(id: id, payload: payload) + } + + /// Creates a new file input attachment from document picker data and starts its upload. + private func uploadFileAttachment( + data: Data, + fileName: String, + contentType: String + ) { + uploadNewInputAttachment( + data: data, + fileName: fileName, + contentType: contentType, + previewData: nil, + kind: .file + ) + } + + /// Adds a new input attachment to the controller and stores the payload needed for retry. + private func uploadNewInputAttachment( + data: Data, + fileName: String, + contentType: String, + previewData: Data?, + kind: InputAttachmentKind + ) { + guard let controller else { + showError(ConsultantControllerError.notConnected) + return + } + + let attachment = controller.addInputAttachment( + fileName: fileName, + contentType: contentType, + previewData: previewData, + messageType: kind.messageType + ) + let payload = InputAttachmentUploadPayload( + data: data, + contentType: contentType, + previewData: previewData + ) + + storeAndUploadInputAttachment(id: attachment.id, payload: payload) + } + + /// Persists a retry payload and starts uploading it for the matching input attachment. + private func storeAndUploadInputAttachment( + id: UUID, + payload: InputAttachmentUploadPayload + ) { + inputAttachmentUploadPayloads[id] = payload + uploadInputAttachment(id: id, payload: payload) + } + + /// Starts or replaces the upload task for an input attachment. + private func uploadInputAttachment( + id: UUID, + payload: InputAttachmentUploadPayload + ) { + guard let controller else { + showError(ConsultantControllerError.notConnected) + return + } + + guard controller.inputAttachments.contains(where: { $0.id == id }) else { + return + } + + uploadAttachmentTasks[id]?.cancel() + uploadAttachmentTasks[id] = Task { [weak self, controller] in + defer { + self?.uploadAttachmentTasks[id] = nil + } + + do { + try await controller.uploadInputAttachment( + id: id, + data: payload.data, + contentType: payload.contentType, + previewData: payload.previewData + ) + } catch ConsultantControllerError.inputAttachmentRemoved { + return + } catch { + guard !Task.isCancelled else { + return + } + + self?.showError(error) + } + } + } + + /// Reads a security-scoped document URL, validates its size, and uploads it as a file attachment. + private func loadFileAttachment(from url: URL) { + let hasSecurityScopedAccess = url.startAccessingSecurityScopedResource() + defer { + if hasSecurityScopedAccess { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let fileSize = try url.fileSize() + try validateDocumentFileSize(fileSize) + + let data = try Data(contentsOf: url) + if fileSize == nil { + try validateDocumentFileSize(data.count) + } + + uploadFileAttachment( + data: data, + fileName: url.fileName, + contentType: url.contentType + ) + } catch { + showError(error) + } + } + + /// Validates a document file size against the SDK upload limit. + private func validateDocumentFileSize(_ fileSize: Int?) throws { + guard let fileSize else { + return + } + + guard fileSize <= Self.maxDocumentAttachmentFileSize else { + throw AttachmentPickerError.fileTooLarge + } + } + + /// Validates an image file size against the SDK upload limit. + private func validateImageSize(_ fileSize: Int) throws { + guard fileSize <= Self.maxImageAttachmentFileSize else { + throw AttachmentPickerError.imageTooLarge + } + } + + private func showError(_ error: Error) { + onError?(error) + } + + private func showAlert(title: String, message: String) { + onAlert?(title, message) + } + + private func removeUploadPayloads( + for ids: IDSequence + ) where IDSequence.Element == UUID { + ids.forEach { inputAttachmentUploadPayloads[$0] = nil } + } + + package override init() { + super.init() + } +} + +extension InputBarAttachmentCoordinator: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + /// Dismisses camera capture when the user cancels it. + package func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + + /// Converts camera output into JPEG data and uploads it as an image input attachment. + package func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true) + + guard let image = info[.originalImage] as? UIImage else { + handlePickedImageError( + AttachmentPickerError.emptyImageData, + failAttachmentID: nil + ) + return + } + + handlePickedImageData( + image.jpegData(compressionQuality: 0.9), + failAttachmentID: nil + ) { data in + uploadNewInputAttachment( + data: data, + fileName: "photo-\(UUID().uuidString).jpg", + contentType: "image/jpeg", + previewData: data, + kind: .image + ) + } + } +} + +extension InputBarAttachmentCoordinator: UIDocumentPickerDelegate { + + /// Uploads the selected documents, respecting the remaining input attachment slots. + package func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + let remainingSlots = remainingInputAttachmentSlots + guard remainingSlots > 0 else { + showError(AttachmentPickerError.tooManyAttachments) + return + } + + if urls.count > remainingSlots { + showError(AttachmentPickerError.tooManyAttachments) + } + + urls.prefix(remainingSlots).forEach(loadFileAttachment) + } +} + +extension InputBarAttachmentCoordinator: PHPickerViewControllerDelegate { + + /// Creates placeholder image attachments and loads their binary data from the photo picker. + package func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + guard let controller else { + showError(ConsultantControllerError.notConnected) + return + } + + results.forEach { result in + let itemProvider = result.itemProvider + let metadata = ImagePickerMetadata.make(from: itemProvider) + let attachment = controller.addInputAttachment( + fileName: metadata.fileName, + contentType: metadata.contentType, + messageType: InputAttachmentKind.image.messageType + ) + + loadImageAttachment( + result, + attachment: attachment, + typeIdentifier: metadata.typeIdentifier, + contentType: metadata.contentType + ) + } + } + + /// Loads image data from the picker item provider and uploads it into the existing attachment. + private func loadImageAttachment( + _ result: PHPickerResult, + attachment: InputAttachment, + typeIdentifier: String, + contentType: String + ) { + let itemProvider = result.itemProvider + + itemProvider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] data, error in + Task { @MainActor in + guard let self else { + return + } + + if let error { + self.handlePickedImageError(error, failAttachmentID: attachment.id) + return + } + + self.handlePickedImageData( + data, + failAttachmentID: attachment.id + ) { imageData in + self.uploadImageAttachment( + id: attachment.id, + data: imageData, + contentType: contentType + ) + } + } + } + } +} + +private extension InputBarAttachmentCoordinator { + + /// Local input attachment kind mapped to the message type expected by the controller. + enum InputAttachmentKind { + case file + case image + + package var messageType: String { + switch self { + case .file: + return "file" + case .image: + return "image" + } + } + } + + /// Upload payload retained while an input attachment can be retried. + struct InputAttachmentUploadPayload { + package let data: Data + package let contentType: String + package let previewData: Data? + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/MessageAttachmentCoordinator.swift b/Sources/OnlineConsultant/UI/Helpers/MessageAttachmentCoordinator.swift new file mode 100644 index 0000000..1a76664 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/MessageAttachmentCoordinator.swift @@ -0,0 +1,232 @@ +import UIKit + +/// Coordinates attachment preview preparation for message bubbles. +/// +/// The coordinator resolves already cached files, downloads remote preview files when needed, +/// tracks loading identifiers for file and image bubbles, and presents the ready Quick Look preview. +@MainActor +package final class MessageAttachmentCoordinator { + + /// View controller used to present attachment previews. + package weak var presentingViewController: UIViewController? + + /// Called when downloading a remote preview file fails. + package var onError: ((Error) -> Void)? + + /// Called whenever loading state for file-message previews changes. + package var onLoadingFilePreviewIDsChange: ((Set) -> Void)? + + /// Called whenever loading state for image-message previews changes. + package var onLoadingImagePreviewIDsChange: ((Set) -> Void)? + + private var controller: OnlineConsultantController? + private var filePreviewTasks: [String: Task] = [:] + private var loadingFilePreviewIDs: Set = [] + private var loadingImagePreviewIDs: Set = [] + + deinit { + filePreviewTasks.values.forEach { $0.cancel() } + } + + /// Updates the controller used for cached file lookup and remote preview downloads. + package func setController(_ controller: OnlineConsultantController?) { + self.controller = controller + } + + /// Cancels all active remote preview downloads. + package func cancelTasks() { + filePreviewTasks.values.forEach { $0.cancel() } + filePreviewTasks.removeAll() + } + + /// Clears transient preview state when the bound consultant controller changes. + package func resetState() { + cancelTasks() + resetPreviewLoading() + } + + /// Opens an image preview, converting image-specific items to generic file preview items first. + package func showImagePreview( + items: [ImagePreviewItem], + startIndex: Int + ) { + showAttachmentPreview( + items: items.enumerated().map { index, item in + item.filePreviewItem(index: index) + }, + startIndex: startIndex, + kind: .image + ) + } + + /// Opens a file preview from file-message preview items. + package func showFilePreview( + items: [FilePreviewItem], + startIndex: Int + ) { + showAttachmentPreview( + items: items, + startIndex: startIndex, + kind: .file + ) + } + + private func showAttachmentPreview( + items: [FilePreviewItem], + startIndex: Int, + kind: MessageAttachmentKind + ) { + let cachedItems = filePreviewItemsByApplyingCachedFiles(items) + if let preview = FilePreviewResolver.localPreviewItems( + from: cachedItems, + startIndex: startIndex + ) { + presentFilePreview(preview) + return + } + + guard let selectedItem = FilePreviewResolver.selectedItem( + from: cachedItems, + startIndex: startIndex + ) else { + return + } + + cacheAndPresentRemotePreview( + selectedItem: selectedItem, + cachedItems: cachedItems, + startIndex: startIndex, + kind: kind + ) + } + + private func cacheAndPresentRemotePreview( + selectedItem: FilePreviewItem, + cachedItems: [FilePreviewItem], + startIndex: Int, + kind: MessageAttachmentKind + ) { + guard + !selectedItem.url.isFileURL, + let fileID = FilePreviewResolver.normalizedFileID(selectedItem.id), + let controller, + filePreviewTasks[fileID] == nil + else { + return + } + + setPreviewLoading(true, fileID: fileID, kind: kind) + filePreviewTasks[fileID] = Task { [weak self, controller] in + defer { + self?.filePreviewTasks[fileID] = nil + self?.setPreviewLoading(false, fileID: fileID, kind: kind) + } + + do { + let localURL = try await controller.cacheRemoteFile( + fileID: fileID, + remoteURL: selectedItem.url + ) + guard !Task.isCancelled else { + return + } + + let resolvedItems = cachedItems.map { item in + FilePreviewResolver.normalizedFileID(item.id) == fileID + ? item.replacingURL(localURL) + : item + } + guard let preview = FilePreviewResolver.localPreviewItems( + from: resolvedItems, + startIndex: startIndex + ) else { + return + } + + self?.presentFilePreview(preview) + } catch { + guard !Task.isCancelled else { + return + } + + self?.onError?(error) + } + } + } + + private func presentFilePreview( + _ preview: (items: [FilePreviewItem], startIndex: Int) + ) { + guard + let presentingViewController, + presentingViewController.presentedViewController == nil + else { + return + } + + let viewController = FilePreviewViewController( + items: preview.items, + startIndex: preview.startIndex + ) + presentingViewController.present(viewController, animated: true) + } + + private func filePreviewItemsByApplyingCachedFiles( + _ items: [FilePreviewItem] + ) -> [FilePreviewItem] { + guard let controller else { + return items + } + + return items.map { item in + guard + let fileID = FilePreviewResolver.normalizedFileID(item.id), + let cachedURL = controller.cachedFileURL(fileID: fileID) + else { + return item + } + + return item.replacingURL(cachedURL) + } + } + + private func setPreviewLoading( + _ isLoading: Bool, + fileID: String, + kind: MessageAttachmentKind + ) { + switch kind { + case .file: + if isLoading { + loadingFilePreviewIDs.insert(fileID) + } else { + loadingFilePreviewIDs.remove(fileID) + } + onLoadingFilePreviewIDsChange?(loadingFilePreviewIDs) + case .image: + if isLoading { + loadingImagePreviewIDs.insert(fileID) + } else { + loadingImagePreviewIDs.remove(fileID) + } + onLoadingImagePreviewIDsChange?(loadingImagePreviewIDs) + } + } + + private func resetPreviewLoading() { + loadingFilePreviewIDs = [] + loadingImagePreviewIDs = [] + onLoadingFilePreviewIDsChange?([]) + onLoadingImagePreviewIDsChange?([]) + } + + package init() {} +} + +private extension MessageAttachmentCoordinator { + + enum MessageAttachmentKind { + case file + case image + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/MessageDateSeparator.swift b/Sources/OnlineConsultant/UI/Helpers/MessageDateSeparator.swift new file mode 100644 index 0000000..e9aa8c9 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/MessageDateSeparator.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Calendar-day boundaries and localized labels for the chat timeline. +package enum MessageDateSeparator { + package static func date( + messages: [ConsultantMessage], + index: Int, + calendar: Calendar = .autoupdatingCurrent + ) -> Date? { + guard messages.indices.contains(index), + let value = messages[index].time, + let date = DateParser.date(from: value) else { return nil } + + if index > 0, + let previousValue = messages[index - 1].time, + let previousDate = DateParser.date(from: previousValue), + calendar.isDate(date, inSameDayAs: previousDate) { + return nil + } + return calendar.startOfDay(for: date) + } + + package static func areOnDifferentDays(_ first: ConsultantMessage, _ second: ConsultantMessage) -> Bool { + guard let firstValue = first.time, let secondValue = second.time, + let firstDate = DateParser.date(from: firstValue), + let secondDate = DateParser.date(from: secondValue) else { return false } + return !Calendar.autoupdatingCurrent.isDate(firstDate, inSameDayAs: secondDate) + } + + package static func text( + for date: Date, + now: Date = Date(), + calendar: Calendar = .autoupdatingCurrent, + locale: Locale = Locale(identifier: Bundle.module.preferredLocalizations.first ?? "ru") + ) -> String { + if calendar.isDate(date, inSameDayAs: now) { + return ConsultantLocalization.messageDateToday.localized + } + if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), + calendar.isDate(date, inSameDayAs: yesterday) { + return ConsultantLocalization.messageDateYesterday.localized + } + + let formatter = DateFormatter() + formatter.locale = locale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = calendar.component(.year, from: date) == calendar.component(.year, from: now) + ? ConsultantLocalization.messageDateFormat.localized + : ConsultantLocalization.messageDateYearFormat.localized + return formatter.string(from: date) + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/MessageGrouping.swift b/Sources/OnlineConsultant/UI/Helpers/MessageGrouping.swift new file mode 100644 index 0000000..0da172d --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/MessageGrouping.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Calculates visual grouping flags for adjacent messages in the chat list. +package enum MessageGrouping { + + /// Returns whether an incoming message should display the sender avatar. + package static func showsAvatar( + messages: [ConsultantMessage], + index: Int + ) -> Bool { + guard messages.indices.contains(index) else { + return false + } + + let message = messages[index] + guard message.fromMe != true else { + return false + } + + return isLastInSenderGroup( + messages: messages, + index: index + ) + } + + /// Returns whether a message should display the author name above the bubble. + package static func showsAuthor( + messages: [ConsultantMessage], + index: Int + ) -> Bool { + guard messages.indices.contains(index) else { + return false + } + + return isFirstInSenderGroup( + messages: messages, + index: index + ) + } + + /// Returns whether a message bubble should round all corners instead of joining with the next bubble. + package static func roundsAllCorners( + messages: [ConsultantMessage], + index: Int + ) -> Bool { + guard messages.indices.contains(index) else { + return false + } + + return !isLastInSenderGroup( + messages: messages, + index: index + ) + } + + /// Returns whether the message starts a consecutive group from the same sender. + private static func isFirstInSenderGroup( + messages: [ConsultantMessage], + index: Int + ) -> Bool { + guard messages.indices.contains(index) else { + return false + } + + let previousIndex = index - 1 + guard messages.indices.contains(previousIndex) else { + return true + } + + return !messages[index].hasSameSender(as: messages[previousIndex]) + || MessageDateSeparator.areOnDifferentDays(messages[index], messages[previousIndex]) + } + + /// Returns whether the message ends a consecutive group from the same sender. + private static func isLastInSenderGroup( + messages: [ConsultantMessage], + index: Int + ) -> Bool { + guard messages.indices.contains(index) else { + return false + } + + let nextIndex = index + 1 + guard messages.indices.contains(nextIndex) else { + return true + } + + return !messages[index].hasSameSender(as: messages[nextIndex]) + || MessageDateSeparator.areOnDifferentDays(messages[index], messages[nextIndex]) + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/MessageMetadataFormatter.swift b/Sources/OnlineConsultant/UI/Helpers/MessageMetadataFormatter.swift new file mode 100644 index 0000000..bdd418d --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/MessageMetadataFormatter.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Formats backend message timestamps for message metadata UI. +package enum MessageMetadataFormatter { + + /// Returns the display timestamp for a consultant message. + package static func metadataText(for message: ConsultantMessage) -> String? { + formattedTime(from: message.time) + } + + /// Parses a backend timestamp string and returns `HH:mm` display text. + private static func formattedTime(from value: String?) -> String? { + guard let value, !value.isEmpty else { + return nil + } + + guard let date = DateParser.date(from: value) else { + return fallbackTime(from: value) + } + + return timeFormatter.string(from: date) + } + + /// Extracts `HH:mm` directly from timestamp-like strings when parsing fails. + private static func fallbackTime(from value: String) -> String? { + guard + let timeStart = value.firstIndex(of: "T"), + value.distance(from: timeStart, to: value.endIndex) >= 6 + else { + return nil + } + + let start = value.index(after: timeStart) + let end = value.index(start, offsetBy: 5) + return String(value[start.. UIImage? { + UIImage( + named: name, + in: .module, + compatibleWith: nil + ) + } + + private func systemImage(named name: String) -> UIImage? { + UIImage(systemName: name) + } + + private func bundledFileURL( + named name: String, + fileExtension: String, + subdirectory: String + ) -> URL? { + Bundle.module.url( + forResource: name, + withExtension: fileExtension, + subdirectory: subdirectory + ) ?? Bundle.module.url( + forResource: name, + withExtension: fileExtension + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Helpers/ProductImagePreviewURL.swift b/Sources/OnlineConsultant/UI/Helpers/ProductImagePreviewURL.swift new file mode 100644 index 0000000..584751f --- /dev/null +++ b/Sources/OnlineConsultant/UI/Helpers/ProductImagePreviewURL.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Builds product and image preview URLs using backend-provided image workers. +package enum ProductImagePreviewURL { + + /// Returns a resized worker URL when an image worker is available, otherwise the original image URL. + package static func make( + imageURLString: String?, + imageWorkers: [String], + resize: String = "160x160" + ) -> URL? { + guard let normalizedImageURLString = normalizedURLString(imageURLString) else { + return nil + } + + guard let worker = imageWorkers.compactMap(normalizedWorkerHost).first else { + return URL(string: normalizedImageURLString) + } + + return URL(string: "https://\(worker)/r/\(resize)/\(normalizedImageURLString)") + } + + /// Normalizes and percent-encodes an image URL string for direct or worker loading. + private static func normalizedURLString(_ value: String?) -> String? { + guard let value else { + return nil + } + + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty else { + return nil + } + + let valueWithScheme = trimmedValue.hasPrefix("//") + ? "https:\(trimmedValue)" + : trimmedValue + + // Backend links may already contain escaped paths and signed query parameters. + // Encoding '%' again changes their meaning and can invalidate the signature. + var allowedCharacters = CharacterSet.urlQueryAllowed + allowedCharacters.insert(charactersIn: "%") + return valueWithScheme.addingPercentEncoding(withAllowedCharacters: allowedCharacters) + ?? valueWithScheme + } + + /// Normalizes backend image worker values to host names accepted by the worker URL format. + private static func normalizedWorkerHost(_ value: String) -> String? { + var trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedValue.isEmpty else { + return nil + } + + if trimmedValue.hasPrefix("https://") { + trimmedValue.removeFirst("https://".count) + } else if trimmedValue.hasPrefix("http://") { + trimmedValue.removeFirst("http://".count) + } else if trimmedValue.hasPrefix("//") { + trimmedValue.removeFirst("//".count) + } + + trimmedValue = trimmedValue.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return trimmedValue.isEmpty ? nil : trimmedValue + } +} diff --git a/Sources/OnlineConsultant/UI/Models/AttachmentPickerError.swift b/Sources/OnlineConsultant/UI/Models/AttachmentPickerError.swift new file mode 100644 index 0000000..64dbb82 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/AttachmentPickerError.swift @@ -0,0 +1,38 @@ +// +// OnlineConsultantAttachmentPickerError.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import Foundation + +/// User-facing errors produced while picking or preparing input attachments. +package enum AttachmentPickerError: LocalizedError { + + /// Picked image exists, but image data could not be read. + case emptyImageData + + /// Picked image exceeds the SDK image upload size limit. + case imageTooLarge + + /// Picked file exceeds the SDK file upload size limit. + case fileTooLarge + + /// User selected more attachments than the input allows. + case tooManyAttachments + + /// Localized message displayed in the attachment error alert. + package var errorDescription: String? { + switch self { + case .emptyImageData: + return ConsultantLocalization.attachmentErrorEmptyImageData.localized + case .imageTooLarge: + return ConsultantLocalization.attachmentErrorImageTooLarge.localized + case .fileTooLarge: + return ConsultantLocalization.attachmentErrorFileTooLarge.localized + case .tooManyAttachments: + return ConsultantLocalization.attachmentErrorTooManyAttachments.localized + } + } +} diff --git a/Sources/OnlineConsultant/UI/Models/ContactFormMessageImage.swift b/Sources/OnlineConsultant/UI/Models/ContactFormMessageImage.swift new file mode 100644 index 0000000..ce7d92d --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/ContactFormMessageImage.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Image resolved for a contact-form message. +package struct ContactFormMessageImage: Equatable { + + /// Local, remote, data, or file URL used to render the image. + package let url: URL + + /// Optional source image size used to keep the rendered image aspect ratio. + package let size: CGSize? + + package init(url: URL, size: CGSize?) { + self.url = url + self.size = size + } +} diff --git a/Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachment.swift b/Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachment.swift new file mode 100644 index 0000000..44af86d --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachment.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Local UI state for a file selected in the message input before it is sent. + package struct InputAttachment: Equatable, Sendable { + + /// Stable local identifier used to track upload and preview updates. + package let id: UUID + + /// Original file name displayed in the input attachment list. + package let fileName: String + + /// MIME content type used for upload and message item creation. + package let contentType: String + + /// Optional local preview image data displayed while the file is attached. + package let previewData: Data? + + /// Message item type sent to backend after upload, for example `file` or `image`. + package let messageType: String + + /// Current upload state for this attachment. + package let status: InputAttachmentStatus + + /// Indicates whether this attachment is ready to be sent in a message. + package var canSend: Bool { + if case .uploaded = status { + return true + } + + return false + } + + /// Uploaded backend file identifier, if upload has completed. + package var uploadedFileID: FlexibleID? { + status.uploadedFileID + } + + /// Creates local attachment state for a selected file. + package init( + id: UUID = UUID(), + fileName: String, + contentType: String = "application/octet-stream", + previewData: Data? = nil, + messageType: String = "file", + status: InputAttachmentStatus = .uploading + ) { + self.id = id + self.fileName = fileName + self.contentType = contentType + self.previewData = previewData + self.messageType = messageType + self.status = status + } + + /// Returns a copy with a new upload status. + package func updatingStatus(_ status: InputAttachmentStatus) -> InputAttachment { + InputAttachment( + id: id, + fileName: fileName, + contentType: contentType, + previewData: previewData, + messageType: messageType, + status: status + ) + } + + /// Returns a copy with updated local preview data. + package func updatingPreviewData(_ previewData: Data?) -> InputAttachment { + InputAttachment( + id: id, + fileName: fileName, + contentType: contentType, + previewData: previewData, + messageType: messageType, + status: status + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachmentStatus.swift b/Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachmentStatus.swift new file mode 100644 index 0000000..0b87b2f --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/InputAttachmentModels/InputAttachmentStatus.swift @@ -0,0 +1,28 @@ +// +// InputAttachmentStatus.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +/// Upload state for a file selected in the message input. + package enum InputAttachmentStatus: Equatable, Sendable { + + /// File upload is still in progress. + case uploading + + /// File upload has completed and backend returned a file identifier. + case uploaded(fileID: FlexibleID) + + /// File upload failed with a user-facing error message. + case failed(message: String) + + /// Uploaded backend file identifier, if this status is `uploaded`. + package var uploadedFileID: FlexibleID? { + if case .uploaded(let fileID) = self { + return fileID + } + + return nil + } +} diff --git a/Sources/OnlineConsultant/UI/Models/ManagerIdentity.swift b/Sources/OnlineConsultant/UI/Models/ManagerIdentity.swift new file mode 100644 index 0000000..b65d5a7 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/ManagerIdentity.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Optional manager identity override resolved from consultant configuration for UI rendering. +package struct ManagerIdentity: Equatable { + + /// Custom manager name displayed instead of the message user name. + package let displayName: String? + + /// Custom manager avatar URL displayed instead of the message user avatar. + package let logoURL: URL? + + /// Builds the manager identity only when custom manager display is enabled in consultant config. + package init(config: ConsultantConfig?) { + guard config?.usesCustomManagerDisplay == true else { + displayName = nil + logoURL = nil + return + } + + displayName = config?.managerName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + logoURL = config?.managerLogoURL + } +} diff --git a/Sources/OnlineConsultant/UI/Models/MessageDeliveryStatus.swift b/Sources/OnlineConsultant/UI/Models/MessageDeliveryStatus.swift new file mode 100644 index 0000000..1866997 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/MessageDeliveryStatus.swift @@ -0,0 +1,36 @@ +import Foundation + +/// UI delivery status used by message metadata and status icons. +package enum MessageDeliveryStatus: Equatable { + + /// No delivery status should be displayed. + case none + + /// Message is being created or sent. + case sending + + /// Message has been accepted by backend or transport. + case delivered + + /// Message has been seen by the recipient. + case read + + /// Message failed to send. + case error + + /// Maps backend and local message statuses to UI delivery statuses. + package init(messageStatus: MessageStatus?) { + switch messageStatus { + case nil, .new, .undefined, .sending: + self = .sending + case .sent, .delivered: + self = .delivered + case .seen: + self = .read + case .failed: + self = .error + default: + self = .none + } + } +} diff --git a/Sources/OnlineConsultant/UI/Models/MessageFileDisplayItem.swift b/Sources/OnlineConsultant/UI/Models/MessageFileDisplayItem.swift new file mode 100644 index 0000000..5a24869 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/MessageFileDisplayItem.swift @@ -0,0 +1,46 @@ +import CoreGraphics +import Foundation + +/// UI-ready file or image item extracted from a consultant message. +package struct MessageFileDisplayItem: Equatable { + + /// Backend file item identifier, if one is available. + package let id: String? + + /// File name or fallback title displayed in the bubble. + package let displayName: String + + /// Human-readable file size displayed below the file name. + package let sizeLabel: String? + + /// Remote, data, or local file URL string used for preview and loading. + package let urlString: String? + + /// Optional original image size used by image-message layout. + package let sourceImageSize: CGSize? + + /// Creates a placeholder display item when the message has no usable file item data. + package static func fallback(displayName: String) -> MessageFileDisplayItem { + MessageFileDisplayItem( + id: nil, + displayName: displayName, + sizeLabel: nil, + urlString: nil, + sourceImageSize: nil + ) + } + + package init( + id: String?, + displayName: String, + sizeLabel: String?, + urlString: String?, + sourceImageSize: CGSize? + ) { + self.id = id + self.displayName = displayName + self.sizeLabel = sizeLabel + self.urlString = urlString + self.sourceImageSize = sourceImageSize + } +} diff --git a/Sources/OnlineConsultant/UI/Models/MessageOrderDisplayField.swift b/Sources/OnlineConsultant/UI/Models/MessageOrderDisplayField.swift new file mode 100644 index 0000000..b534f4c --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/MessageOrderDisplayField.swift @@ -0,0 +1,21 @@ +// +// ConsultantMessageOrderDisplayField.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 22.07.2026. +// + +/// UI-ready order detail row displayed below the order items. +package struct MessageOrderDisplayField: Equatable, Sendable { + + /// Localized field title displayed on the left side of the row. + package let title: String + + /// Field value displayed on the right side of the row. + package let value: String + + package init(title: String, value: String) { + self.title = title + self.value = value + } +} diff --git a/Sources/OnlineConsultant/UI/Models/MessageOrderDisplayItem.swift b/Sources/OnlineConsultant/UI/Models/MessageOrderDisplayItem.swift new file mode 100644 index 0000000..291b505 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/MessageOrderDisplayItem.swift @@ -0,0 +1,36 @@ +// +// ConsultantMessageOrderDisplayItem.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 22.07.2026. +// + +import Foundation + +/// UI-ready order item displayed inside an order message bubble. +package struct MessageOrderDisplayItem: Equatable, Sendable { + + /// Display order number, for example `1.`. + package let numberLabel: String + + /// Product name displayed for this order item. + package let name: String + + /// Compact item details string, usually quantity and price. + package let details: String + + /// Optional product URL opened when the item title is tapped. + package let url: URL? + + package init( + numberLabel: String, + name: String, + details: String, + url: URL? + ) { + self.numberLabel = numberLabel + self.name = name + self.details = details + self.url = url + } +} diff --git a/Sources/OnlineConsultant/UI/Models/Previews/FilePreviewItem.swift b/Sources/OnlineConsultant/UI/Models/Previews/FilePreviewItem.swift new file mode 100644 index 0000000..3275b38 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/Previews/FilePreviewItem.swift @@ -0,0 +1,46 @@ +import Foundation + +/// File preview item opened by the SDK preview viewer. +package struct FilePreviewItem: Equatable { + + /// Backend file item identifier, if one is available. + package let id: String? + + /// URL used by the preview viewer. + package let url: URL + + /// File name displayed in the preview viewer. + package let displayName: String + + /// Optional human-readable file size carried from the message bubble. + package let sizeLabel: String? + + /// Preferred file extension used when a preview URL has no useful extension. + package let preferredFileExtension: String? + + /// Creates a file preview item for remote, data, or local file URLs. + package init( + id: String?, + url: URL, + displayName: String, + sizeLabel: String?, + preferredFileExtension: String? = nil + ) { + self.id = id + self.url = url + self.displayName = displayName + self.sizeLabel = sizeLabel + self.preferredFileExtension = preferredFileExtension + } + + /// Returns a copy pointing to a different URL while keeping display metadata. + package func replacingURL(_ url: URL) -> FilePreviewItem { + FilePreviewItem( + id: id, + url: url, + displayName: displayName, + sizeLabel: sizeLabel, + preferredFileExtension: preferredFileExtension + ) + } +} diff --git a/Sources/OnlineConsultant/UI/Models/Previews/ImagePreviewItem.swift b/Sources/OnlineConsultant/UI/Models/Previews/ImagePreviewItem.swift new file mode 100644 index 0000000..3281e59 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/Previews/ImagePreviewItem.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Image preview item opened from an image message bubble. +package struct ImagePreviewItem: Equatable { + + /// Backend file item identifier, if one is available. + package let id: String? + + /// Image URL used by the preview viewer. + package let url: URL + + /// Optional caption used as the preview file name. + package let caption: String? + + /// Converts this image preview into a generic file preview item. + package func filePreviewItem(index: Int) -> FilePreviewItem { + FilePreviewItem( + id: id, + url: url, + displayName: normalizedPreviewDisplayName(index: index), + sizeLabel: nil, + preferredFileExtension: preferredPreviewFileExtension() + ) + } + + /// Returns a user-facing file name for the preview viewer. + private func normalizedPreviewDisplayName(index: Int) -> String { + let normalizedCaption = caption?.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalizedCaption, !normalizedCaption.isEmpty { + return normalizedCaption + } + + let lastPathComponent = url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + let lastPathExtension = URL(fileURLWithPath: lastPathComponent).pathExtension.lowercased() + if !lastPathComponent.isEmpty, + lastPathComponent != "data", + lastPathExtension != "data" { + return lastPathComponent + } + + return "photo-\(index + 1)" + } + + /// Returns an image file extension suitable for generated preview file names. + private func preferredPreviewFileExtension() -> String { + let pathExtension = url.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if Self.previewImageExtensions.contains(pathExtension) { + return pathExtension + } + + return "jpg" + } + + /// Image file extensions preserved by the preview viewer. + private static let previewImageExtensions: Set = [ + "gif", + "heic", + "heif", + "jpeg", + "jpg", + "png", + "webp" + ] + + package init( + id: String?, + url: URL, + caption: String? + ) { + self.id = id + self.url = url + self.caption = caption + } +} diff --git a/Sources/OnlineConsultant/UI/Models/Previews/MessageQuotePreview.swift b/Sources/OnlineConsultant/UI/Models/Previews/MessageQuotePreview.swift new file mode 100644 index 0000000..fb67b74 --- /dev/null +++ b/Sources/OnlineConsultant/UI/Models/Previews/MessageQuotePreview.swift @@ -0,0 +1,11 @@ +import Foundation + +/// UI-ready preview of the message being quoted. +package enum MessageQuotePreview: Equatable, Sendable { + + /// Text preview displayed inside the quote block. + case text(String) + + /// Files or images from the quoted message, with its optional text. + case attachments([MessageItem], isImage: Bool, text: String?, author: String) +} diff --git a/Sources/OnlineConsultant/UI/Style/ConsultantTheme.swift b/Sources/OnlineConsultant/UI/Style/ConsultantTheme.swift new file mode 100644 index 0000000..f0289ac --- /dev/null +++ b/Sources/OnlineConsultant/UI/Style/ConsultantTheme.swift @@ -0,0 +1,167 @@ +import UIKit + +/// Runtime color theme used by the SDK chat UI. +package struct ConsultantTheme { + + /// Main accent color resolved from the consultant configuration. + package let brandColor: UIColor + + /// Semi-transparent accent color used for focus, highlights, and subtle backgrounds. + package let brandAlphaColor: UIColor + + /// Background color for the chat screen. + package let screenBackgroundColor: UIColor + + /// Background color for the input bar area. + package let bottomBarColor: UIColor + + /// Background color for incoming consultant message bubbles. + package let consultantBubbleColor: UIColor + + /// Background color for outgoing visitor message bubbles. + package let visitorBubbleColor: UIColor + + /// Primary text color used on top of the accent color. + package let mainTextColor: UIColor + + /// Primary icon tint used on top of the accent color. + package let mainIconColor: UIColor + + /// Main readable text color for neutral surfaces and incoming bubbles. + package let primaryTextColor: UIColor + + /// Timestamp and secondary metadata color for incoming messages. + package let messageDateColor: UIColor + + /// Placeholder text color for inputs and empty UI states. + package let placeholderTextColor: UIColor + + /// Divider and neutral border color. + package let dividerColor: UIColor + + /// Background color for text input and form fields. + package let areaBackgroundColor: UIColor + + /// Text color for text input and form fields. + package let areaTextColor: UIColor + + /// Icon tint for controls inside input and form areas. + package let areaIconColor: UIColor + + /// Border color for text input and form fields. + package let areaBorderColor: UIColor + + /// Whether consultant-controlled elements should use rounded styling. + package let roundedElements: Bool + + /// Whether the consultant uses the dark background theme. + package let isDarkTheme: Bool + + /// Default light theme matching the web consultant fallback palette. + package static let defaultTheme = ConsultantTheme( + brandColor: UIColor(hex: 0x005EEB), + brandAlphaColor: UIColor(hex: 0x005EEB, alpha: 0.12), + screenBackgroundColor: .white, + bottomBarColor: .white, + consultantBubbleColor: UIColor(hex: 0xF4F6F8), + visitorBubbleColor: UIColor(hex: 0x005EEB), + mainTextColor: .white, + mainIconColor: .white, + primaryTextColor: UIColor(hex: 0x1E2248), + messageDateColor: UIColor(hex: 0x8A96A6), + placeholderTextColor: UIColor(hex: 0xAFB9C3), + dividerColor: UIColor(hex: 0xDEE2E6), + areaBackgroundColor: .white, + areaTextColor: .black, + areaIconColor: UIColor(hex: 0x636F7F, alpha: 0.47), + areaBorderColor: UIColor(hex: 0xDEE2E6), + roundedElements: true, + isDarkTheme: false + ) + + /// Dark theme screen background matching the web consultant dark palette. + private static let darkBackgroundColor = UIColor(hex: 0x0A0A0A) + + /// Creates a theme from backend consultant config or falls back to the default theme. + package init(config: ConsultantConfig?) { + guard let config else { + self = Self.defaultTheme + return + } + + let defaultTheme = Self.defaultTheme + let brandColor = UIColor(hexString: config.elementsColor) ?? defaultTheme.brandColor + let mainTextColor = config.inverse ? defaultTheme.primaryTextColor : defaultTheme.mainTextColor + let darkTheme = config.isDarkBackground + + self.init( + brandColor: brandColor, + brandAlphaColor: brandColor.withAlphaComponent(0.12), + screenBackgroundColor: darkTheme ? Self.darkBackgroundColor : defaultTheme.screenBackgroundColor, + bottomBarColor: darkTheme ? Self.darkBackgroundColor : defaultTheme.bottomBarColor, + consultantBubbleColor: darkTheme + ? defaultTheme.mainTextColor.withAlphaComponent(0.10) + : defaultTheme.consultantBubbleColor, + visitorBubbleColor: brandColor, + mainTextColor: mainTextColor, + mainIconColor: mainTextColor, + primaryTextColor: darkTheme ? defaultTheme.mainTextColor : defaultTheme.primaryTextColor, + messageDateColor: darkTheme ? defaultTheme.placeholderTextColor : defaultTheme.messageDateColor, + placeholderTextColor: defaultTheme.placeholderTextColor, + dividerColor: darkTheme ? .clear : defaultTheme.dividerColor, + areaBackgroundColor: darkTheme + ? defaultTheme.mainTextColor.withAlphaComponent(0.10) + : defaultTheme.areaBackgroundColor, + areaTextColor: darkTheme ? defaultTheme.mainTextColor : defaultTheme.areaTextColor, + areaIconColor: darkTheme + ? defaultTheme.mainTextColor.withAlphaComponent(0.40) + : defaultTheme.areaIconColor, + areaBorderColor: darkTheme + ? defaultTheme.mainTextColor.withAlphaComponent(0.10) + : defaultTheme.areaBorderColor, + roundedElements: config.roundedElements, + isDarkTheme: darkTheme + ) + } + + /// Creates a fully resolved theme without consulting backend configuration. + private init( + brandColor: UIColor, + brandAlphaColor: UIColor, + screenBackgroundColor: UIColor, + bottomBarColor: UIColor, + consultantBubbleColor: UIColor, + visitorBubbleColor: UIColor, + mainTextColor: UIColor, + mainIconColor: UIColor, + primaryTextColor: UIColor, + messageDateColor: UIColor, + placeholderTextColor: UIColor, + dividerColor: UIColor, + areaBackgroundColor: UIColor, + areaTextColor: UIColor, + areaIconColor: UIColor, + areaBorderColor: UIColor, + roundedElements: Bool, + isDarkTheme: Bool + ) { + self.brandColor = brandColor + self.brandAlphaColor = brandAlphaColor + self.screenBackgroundColor = screenBackgroundColor + self.bottomBarColor = bottomBarColor + self.consultantBubbleColor = consultantBubbleColor + self.visitorBubbleColor = visitorBubbleColor + self.mainTextColor = mainTextColor + self.mainIconColor = mainIconColor + self.primaryTextColor = primaryTextColor + self.messageDateColor = messageDateColor + self.placeholderTextColor = placeholderTextColor + self.dividerColor = dividerColor + self.areaBackgroundColor = areaBackgroundColor + self.areaTextColor = areaTextColor + self.areaIconColor = areaIconColor + self.areaBorderColor = areaBorderColor + self.roundedElements = roundedElements + self.isDarkTheme = isDarkTheme + } +} diff --git a/Sources/OnlineConsultant/UI/Style/MessageBubbleStyle.swift b/Sources/OnlineConsultant/UI/Style/MessageBubbleStyle.swift new file mode 100644 index 0000000..911bb2b --- /dev/null +++ b/Sources/OnlineConsultant/UI/Style/MessageBubbleStyle.swift @@ -0,0 +1,74 @@ +import UIKit + +/// Shared visual tokens used by message bubble views. +package enum MessageBubbleStyle { + + /// Default text color for bubble content that is not driven by the runtime theme. + package static let textColor = UIColor(red: 30 / 255, green: 34 / 255, blue: 72 / 255, alpha: 1.0) + + /// Background color for image or file placeholders. + package static let placeholderBackgroundColor = UIColor(red: 222 / 255, green: 226 / 255, blue: 230 / 255, alpha: 1) + + /// Tint color for placeholder icons. + package static let placeholderIconColor = UIColor(red: 138 / 255, green: 150 / 255, blue: 166 / 255, alpha: 1) + + /// Color used for positive stock or payment states. + package static let inStockColor = UIColor(red: 32 / 255, green: 167 / 255, blue: 127 / 255, alpha: 1) + + /// Color used for negative stock or payment states. + package static let outOfStockColor = UIColor(red: 221 / 255, green: 64 / 255, blue: 64 / 255, alpha: 1) + + /// Accent color for the leading bar in quote previews. + package static let quoteBarColor = UIColor(red: 175 / 255, green: 185 / 255, blue: 195 / 255, alpha: 1) + + /// Text color for quoted message previews. + package static let quoteTextColor = UIColor(red: 138 / 255, green: 150 / 255, blue: 166 / 255, alpha: 1) + + /// Font used for author names above incoming bubbles. + package static let authorFont = UIFont.systemFont(ofSize: 13, weight: .semibold) + + /// Regular 13pt font used for compact secondary labels. + package static let regularSmallFont = UIFont.systemFont(ofSize: 13, weight: .regular) + + /// Regular 14pt font used for compact body labels. + package static let regularCompactFont = UIFont.systemFont(ofSize: 14, weight: .regular) + + /// Semibold 14pt font used for compact emphasized labels. + package static let semiboldCompactFont = UIFont.systemFont(ofSize: 14, weight: .semibold) + + /// Semibold 15pt font used for medium emphasis. + package static let semiboldMediumFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + + /// Default body font for text message content. + package static let messageFont = UIFont.systemFont(ofSize: 17, weight: .regular) + + /// Semibold 17pt font used for large emphasized text. + package static let semiboldLargeFont = UIFont.systemFont(ofSize: 17, weight: .semibold) + + /// Font used for message timestamps and delivery metadata. + package static let metadataFont = UIFont.systemFont(ofSize: 11, weight: .regular) + + /// Returns the readable text color for an incoming or outgoing bubble. + package static func textColor( + isOutgoing: Bool, + theme: ConsultantTheme + ) -> UIColor { + isOutgoing ? theme.mainTextColor : theme.primaryTextColor + } + + /// Returns the secondary text color for an incoming or outgoing bubble. + package static func secondaryColor( + isOutgoing: Bool, + theme: ConsultantTheme + ) -> UIColor { + isOutgoing ? theme.mainTextColor.withAlphaComponent(0.74) : theme.messageDateColor + } + + /// Returns the background color for an incoming or outgoing bubble. + package static func bubbleColor( + isOutgoing: Bool, + theme: ConsultantTheme + ) -> UIColor { + isOutgoing ? theme.visitorBubbleColor : theme.consultantBubbleColor + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/FilePreviewViewController.swift b/Sources/OnlineConsultant/UI/ViewControllers/FilePreviewViewController.swift new file mode 100644 index 0000000..e13b399 --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/FilePreviewViewController.swift @@ -0,0 +1,233 @@ +import QuickLook +import UIKit + +/// QuickLook-backed viewer for file and image attachments opened from chat messages. +package final class FilePreviewViewController: QLPreviewController { + + private let quickLookItems: [FileQuickLookItem] + private let initialPreviewItemIndex: Int + private let temporaryPreviewDirectory: URL? + + /// Root directory used for temporary QuickLook copies. + private static var temporaryPreviewRootDirectory: URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("OnlineConsultant", isDirectory: true) + .appendingPathComponent("FilePreview", isDirectory: true) + } + + package init( + items: [FilePreviewItem], + startIndex: Int + ) { + Self.removeStaleTemporaryPreviewDirectories() + let preparedItems = Self.makeQuickLookItems(from: items) + let quickLookItems = preparedItems.items + self.quickLookItems = quickLookItems + temporaryPreviewDirectory = preparedItems.temporaryDirectory + initialPreviewItemIndex = Self.clampedIndex( + startIndex, + itemCount: quickLookItems.count + ) + super.init(nibName: nil, bundle: nil) + dataSource = self + currentPreviewItemIndex = initialPreviewItemIndex + } + + /// Storyboard initialization is not supported for the SDK preview controller. + @available(*, unavailable) + package required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let temporaryPreviewDirectory { + try? FileManager.default.removeItem(at: temporaryPreviewDirectory) + } + } + + /// Clamps a requested preview index into the available item range. + private static func clampedIndex( + _ index: Int, + itemCount: Int + ) -> Int { + guard itemCount > 0 else { + return 0 + } + + return min(max(index, 0), itemCount - 1) + } + + /// Converts SDK preview models into QuickLook items and tracks temporary local copies. + private static func makeQuickLookItems( + from items: [FilePreviewItem] + ) -> (items: [FileQuickLookItem], temporaryDirectory: URL?) { + let temporaryDirectory = temporaryPreviewRootDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + var didCreateTemporaryFile = false + + let quickLookItems = items.map { item in + let preparedPreview = preparedPreviewURL( + for: item, + temporaryDirectory: temporaryDirectory + ) + didCreateTemporaryFile = didCreateTemporaryFile || preparedPreview.didCreateTemporaryFile + + return FileQuickLookItem( + item: item, + previewURL: preparedPreview.url + ) + } + + return ( + quickLookItems, + didCreateTemporaryFile ? temporaryDirectory : nil + ) + } + + /// Returns a QuickLook-safe URL for an item, copying local files to temporary names when needed. + private static func preparedPreviewURL( + for item: FilePreviewItem, + temporaryDirectory: URL + ) -> (url: URL, didCreateTemporaryFile: Bool) { + guard item.url.isFileURL else { + return (item.url, false) + } + + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: item.url.path) else { + return (item.url, false) + } + + do { + try fileManager.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + let previewURL = temporaryDirectory.appendingPathComponent( + previewFileName(for: item) + ) + if fileManager.fileExists(atPath: previewURL.path) { + try fileManager.removeItem(at: previewURL) + } + try fileManager.copyItem(at: item.url, to: previewURL) + return (previewURL, true) + } catch { + return (item.url, false) + } + } + + /// Removes stale temporary preview directories left by previous preview sessions. + package static func removeStaleTemporaryPreviewDirectories( + now: Date = Date(), + maxAge: TimeInterval = 24 * 60 * 60 + ) { + let fileManager = FileManager.default + let rootDirectory = temporaryPreviewRootDirectory + + guard + let items = try? fileManager.contentsOfDirectory( + at: rootDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + return + } + + items.forEach { itemURL in + guard + let values = try? itemURL.resourceValues(forKeys: [.contentModificationDateKey, .isDirectoryKey]), + values.isDirectory == true, + let modificationDate = values.contentModificationDate, + now.timeIntervalSince(modificationDate) > maxAge + else { + return + } + + try? fileManager.removeItem(at: itemURL) + } + } + + /// Builds a sanitized file name for a temporary QuickLook copy. + private static func previewFileName(for item: FilePreviewItem) -> String { + let displayName = item.displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackName = item.url.lastPathComponent.isEmpty ? "file" : item.url.lastPathComponent + let rawFileName = displayName.isEmpty ? fallbackName : displayName + let sanitizedFileName = rawFileName + .map { character in + Self.invalidFileNameCharacters.contains(character) ? "_" : character + } + let fileName = String(sanitizedFileName).trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedFileName = fileName.isEmpty ? "file" : fileName + + guard URL(fileURLWithPath: normalizedFileName).pathExtension.isEmpty else { + return normalizedFileName + } + + if let preferredFileExtension = normalizedFileExtension(item.preferredFileExtension) { + return normalizedFileName + "." + preferredFileExtension + } + + guard let urlFileExtension = normalizedFileExtension(item.url.pathExtension) else { + return normalizedFileName + } + + return normalizedFileName + "." + urlFileExtension + } + + /// Normalizes a file extension used for generated preview file names. + private static func normalizedFileExtension(_ fileExtension: String?) -> String? { + let normalizedFileExtension = fileExtension? + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + guard + let normalizedFileExtension, + !normalizedFileExtension.isEmpty, + normalizedFileExtension != "data" + else { + return nil + } + + return normalizedFileExtension + } + + /// Characters replaced in temporary QuickLook file names. + private static let invalidFileNameCharacters = Set("/\\:") +} + +extension FilePreviewViewController: QLPreviewControllerDataSource { + + /// Returns the number of items available for QuickLook preview. + package func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + quickLookItems.count + } + + /// Returns the QuickLook item for the requested index. + package func previewController( + _ controller: QLPreviewController, + previewItemAt index: Int + ) -> QLPreviewItem { + quickLookItems[index] + } +} + +/// QuickLook item wrapper around the SDK file preview model. +private final class FileQuickLookItem: NSObject, QLPreviewItem { + + /// URL opened by QuickLook. + let previewItemURL: URL? + + /// Title displayed by QuickLook. + let previewItemTitle: String? + + /// Creates a QuickLook item from SDK preview metadata and a prepared preview URL. + init( + item: FilePreviewItem, + previewURL: URL + ) { + previewItemURL = previewURL + previewItemTitle = item.displayName + super.init() + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/OnlineConsultantViewController.swift b/Sources/OnlineConsultant/UI/ViewControllers/OnlineConsultantViewController.swift new file mode 100644 index 0000000..e08765f --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/OnlineConsultantViewController.swift @@ -0,0 +1,445 @@ +import Core +import UIKit + +/// Ready-to-use chat screen backed by the shared `OnlineConsultantController`. +/// +/// The view controller owns message rendering, input attachment picking and upload, +/// message attachment previews, contact form submission, pagination, and user-facing errors. +@MainActor +public final class OnlineConsultantViewController: UIViewController { + + /// Root chat view managed by the view controller. + package private(set) lazy var consultantView: ConsultantView = { + ConsultantView() + }() + + private let inputBarAttachmentCoordinator = InputBarAttachmentCoordinator() + private let messageAttachmentCoordinator = MessageAttachmentCoordinator() + private var controller: OnlineConsultantController + private var stateTask: Task? + private var messagesTask: Task? + private var inputAttachmentsTask: Task? + private var historyCoordinator: MessageHistoryCoordinator? + private var quoteNavigationTask: Task? + private var quoteNavigationID: String? + private var quoteNavigationRequestID: UUID? + private var loadOlderMessagesTask: Task? + private var didReachStartOfHistory = false + private var isChatVisible = false + + /// Creates a chat screen backed by the process-wide online consultant module. + public convenience init() { + self.init(controller: CrmSdk.shared.onlineConsultant) + } + + package init(controller: OnlineConsultantController) { + self.controller = controller + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + package required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Installs `consultantView` as the root view. + public override func loadView() { + view = consultantView + } + + /// Configures coordinators, view callbacks, and starts controller binding when available. + public override func viewDidLoad() { + super.viewDidLoad() + setupInputBarAttachmentCoordinator() + setupMessageAttachmentCoordinator() + setupConsultantViewCallbacks() + startControllerBinding() + } + + /// Marks the chat screen as visible and refreshes message read state. + /// + /// - Parameters: + /// - animated: Whether the appearance transition is animated. + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + isChatVisible = true + consultantView.markVisibleIncomingMessagesReadIfNeeded() + consultantView.scheduleVisibleIncomingMessagesReadCheck() + } + + /// Stops treating the chat screen as visible. + /// + /// - Parameters: + /// - animated: Whether the appearance transition is animated. + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + isChatVisible = false + quoteNavigationTask?.cancel() + quoteNavigationTask = nil + quoteNavigationID = nil + quoteNavigationRequestID = nil + } + + deinit { + stateTask?.cancel() + messagesTask?.cancel() + inputAttachmentsTask?.cancel() + quoteNavigationTask?.cancel() + loadOlderMessagesTask?.cancel() + } + + /// Updates the chat title after its parent controller changes. + /// + /// - Parameters: + /// - parent: New parent view controller, or `nil` when removed. + public override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + applyControllerTitle(title) + } + + private func setupInputBarAttachmentCoordinator() { + inputBarAttachmentCoordinator.presentingViewController = self + inputBarAttachmentCoordinator.onError = { [weak self] error in + self?.controller.reportError(error) + self?.showError(error) + } + inputBarAttachmentCoordinator.onAlert = { [weak self] title, message in + self?.controller.reportError(ConsultantError.attachment(.operationFailed(message))) + self?.showErrorAlert(title: title, message: message) + } + } + + private func setupMessageAttachmentCoordinator() { + messageAttachmentCoordinator.presentingViewController = self + messageAttachmentCoordinator.onError = { [weak self] error in + self?.controller.reportError(error) + self?.showError(error) + } + messageAttachmentCoordinator.onLoadingFilePreviewIDsChange = { [weak self] fileIDs in + self?.consultantView.setLoadingFilePreviewIDs(fileIDs) + } + messageAttachmentCoordinator.onLoadingImagePreviewIDsChange = { [weak self] fileIDs in + self?.consultantView.setLoadingImagePreviewIDs(fileIDs) + } + } + + private func setupConsultantViewCallbacks() { + consultantView.onSendMessage = { [weak self] text in + self?.sendMessage(text) + } + consultantView.onRetryMessage = { [weak self] id in + self?.retryMessage(id: id) + } + consultantView.onOpenAttachmentMenu = { [weak self] sourceView in + self?.inputBarAttachmentCoordinator.showAttachmentMenu(sourceView: sourceView) + } + consultantView.onRemoveInputAttachment = { [weak self] id in + self?.inputBarAttachmentCoordinator.removeInputAttachment(id: id) + } + consultantView.onRetryInputAttachment = { [weak self] id in + self?.inputBarAttachmentCoordinator.retryInputAttachment(id: id) + } + consultantView.onContactFormSubmit = { [weak self] data in + self?.submitContactForm(data) + } + consultantView.onQuoteTap = { [weak self] id in self?.navigateToQuote(id: id) } + consultantView.onLoadOlderMessages = { [weak self] in + self?.loadOlderMessages() + } + consultantView.onImagePreview = { [weak self] items, startIndex in + self?.messageAttachmentCoordinator.showImagePreview(items: items, startIndex: startIndex) + } + consultantView.onFilePreview = { [weak self] items, startIndex in + self?.messageAttachmentCoordinator.showFilePreview(items: items, startIndex: startIndex) + } + consultantView.onMessageLinkTap = { [weak self] url in + self?.openLink(url) + } + consultantView.onAgreementTap = { [weak self] url in + self?.openLink(url) + } + consultantView.onBrandingFooterTap = { [weak self] url in + self?.openLink(url) + } + consultantView.onVisibleIncomingMessagesRead = { [weak self] messageID in + self?.markIncomingMessagesRead(upTo: messageID) == true + } + consultantView.onStateRetry = { [weak self] in + self?.controller.start() + } + } + + private func markIncomingMessagesRead(upTo messageID: String) -> Bool { + guard isChatVisible else { + return false + } + return controller.markIncomingMessagesRead(upTo: messageID) + } + + private func startControllerBinding() { + cancelBindingTasks() + resetBindingState() + historyCoordinator = MessageHistoryCoordinator( + messages: { [controller] in controller.messages }, + canLoadMore: { [controller] in controller.canLoadOlderMessages }, + loadPage: { [weak self, controller] in + let page = try await controller.loadOlderMessages() + if !page.hasMoreMessages { + self?.didReachStartOfHistory = true + self?.consultantView.setCanLoadOlderMessages(false) + } + return page + } + ) + inputBarAttachmentCoordinator.setController(controller) + messageAttachmentCoordinator.setController(controller) + consultantView.setCanLoadOlderMessages(true) + let blocksMessageInput = controller.isOffline + && controller.consultantConfig?.useOfflineMode == true + consultantView.setInputBarHidden(blocksMessageInput) + consultantView.setCanSendMessages(!blocksMessageInput) + setConsultantConfig(controller.consultantConfig) + consultantView.setContactFormPresentation(controller.contactFormPresentation) + consultantView.setMessages(controller.messages) + consultantView.setInputAttachments(controller.inputAttachments) + apply(controller.internalState) + + stateTask = Task { [weak self, controller] in + for await state in controller.internalStateUpdates() { + guard !Task.isCancelled else { return } + self?.apply(state) + } + } + + messagesTask = Task { [weak self, controller] in + for await messages in controller.messagesUpdates() { + guard !Task.isCancelled else { return } + self?.consultantView.setMessages(messages) + } + } + + inputAttachmentsTask = Task { [weak self, controller] in + for await attachments in controller.inputAttachmentUpdates() { + guard !Task.isCancelled else { return } + self?.inputBarAttachmentCoordinator.pruneUploadPayloads(keeping: attachments) + self?.consultantView.setInputAttachments(attachments) + } + } + } + + private func setConsultantConfig(_ config: ConsultantConfig?) { + consultantView.setConsultantConfig(config) + applyNavigationBarAppearance(config: config) + applyControllerTitle(config?.name) + } + + private func applyNavigationBarAppearance(config: ConsultantConfig?) { + let theme = ConsultantTheme(config: config) + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = theme.brandColor + appearance.shadowColor = .clear + appearance.titleTextAttributes = [.foregroundColor: theme.mainTextColor] + appearance.largeTitleTextAttributes = [.foregroundColor: theme.mainTextColor] + + navigationItem.standardAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.compactScrollEdgeAppearance = appearance + } + + private func applyControllerTitle(_ title: String?) { + self.title = title + } + + private func cancelBindingTasks() { + stateTask?.cancel() + messagesTask?.cancel() + inputAttachmentsTask?.cancel() + inputBarAttachmentCoordinator.cancelTasks() + loadOlderMessagesTask?.cancel() + quoteNavigationTask?.cancel() + historyCoordinator?.cancel() + historyCoordinator = nil + quoteNavigationTask = nil + quoteNavigationID = nil + quoteNavigationRequestID = nil + messageAttachmentCoordinator.cancelTasks() + + stateTask = nil + messagesTask = nil + inputAttachmentsTask = nil + loadOlderMessagesTask = nil + } + + private func resetBindingState() { + inputBarAttachmentCoordinator.resetState() + messageAttachmentCoordinator.resetState() + consultantView.setLoadingOlderMessages(false) + consultantView.setContactFormSubmitting(false) + didReachStartOfHistory = false + } + + private func openLink(_ url: URL) { + guard url.scheme?.isEmpty == false else { + return + } + + UIApplication.shared.open(url, options: [:]) + } + + private func sendMessage(_ text: String) { + let attachments = controller.inputAttachments + consultantView.clearMessageInput() + inputBarAttachmentCoordinator.clearUploadPayloads(for: attachments) + Task { [controller, attachments] in + // The controller reports errors and keeps failed messages available for retry. + try? await controller.sendMessage(text, attachments: attachments) + } + } + + private func retryMessage(id: String) { + Task { [controller] in + try? await controller.retryMessage(id: id) + } + } + + private func showError(_ error: Error) { + showErrorAlert( + title: ConsultantLocalization.commonError.localized, + message: error.localizedDescription + ) + } + + private func submitContactForm(_ data: ContactFormSubmitData) { + consultantView.setContactFormSubmitting(true) + Task { [weak self, controller] in + defer { + if let self, self.controller === controller { + self.consultantView.setContactFormSubmitting(false) + } + } + + do { + try await controller.submitContactForm(data) + } catch { + guard !Task.isCancelled else { return } + guard let self, self.controller === controller else { return } + self.showContactFormSubmitError(error) + } + } + } + + private func showContactFormSubmitError(_ error: Error) { + consultantView.setContactFormSubmitError(error.localizedDescription) + } + + private func showErrorAlert(title: String, message: String) { + guard isViewLoaded, view.window != nil else { + return + } + + let alert = UIAlertController( + title: title, + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction( + title: ConsultantLocalization.commonOk.localized, + style: .default + )) + + present(alert, animated: true) + } + + private func navigateToQuote(id: String) { + guard quoteNavigationID != id, let historyCoordinator else { return } + quoteNavigationTask?.cancel() + quoteNavigationID = id + let requestID = UUID() + quoteNavigationRequestID = requestID + quoteNavigationTask = Task { [weak self, controller, historyCoordinator] in + defer { + if self?.quoteNavigationRequestID == requestID { + self?.quoteNavigationID = nil + self?.quoteNavigationRequestID = nil + self?.quoteNavigationTask = nil + } + } + do { + let found = try await historyCoordinator.findMessage(id: id) + try Task.checkCancellation() + guard let self, self.controller === controller else { return } + if found { + self.consultantView.setMessages(controller.messages) + self.consultantView.revealMessage(id: id) + } else { + let alert = UIAlertController( + title: nil, message: ConsultantLocalization.messageQuoteUnavailable.localized, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: ConsultantLocalization.commonOk.localized, style: .default)) + self.present(alert, animated: true) + } + } catch { + guard !Task.isCancelled, !(error is CancellationError) else { return } + self?.showError(error) + } + } + } + + private func loadOlderMessages() { + guard loadOlderMessagesTask == nil, let historyCoordinator else { + return + } + + consultantView.setLoadingOlderMessages(true) + loadOlderMessagesTask = Task { [weak self, historyCoordinator] in + defer { + self?.consultantView.setLoadingOlderMessages(false) + self?.loadOlderMessagesTask = nil + } + + do { + let olderMessagesPage = try await historyCoordinator.loadNextPage() + if !olderMessagesPage.hasMoreMessages { + self?.didReachStartOfHistory = true + self?.consultantView.setCanLoadOlderMessages(false) + } + } catch { + guard !Task.isCancelled else { return } + self?.consultantView.setCanLoadOlderMessages(self?.didReachStartOfHistory == false) + } + } + } + + private func apply(_ state: ControllerInternalState) { + switch state { + case .ready(let snapshot): + consultantView.setStateViewMode(.hidden) + didReachStartOfHistory = !snapshot.canLoadOlderMessages + consultantView.setInputBarHidden(snapshot.blocksMessageInput) + consultantView.setCanSendMessages(!snapshot.blocksMessageInput) + consultantView.setCanLoadOlderMessages(snapshot.canLoadOlderMessages) + setConsultantConfig(snapshot.consultantConfig) + consultantView.setContactFormPresentation(snapshot.contactFormPresentation) + case .idle: + applyInactiveState(viewMode: .hidden) + case .connecting: + applyInactiveState(viewMode: .loading) + case .failed: + applyInactiveState(viewMode: .error) + } + } + + private func applyInactiveState(viewMode: ConsultantStateView.Mode) { + consultantView.setStateViewMode(viewMode) + didReachStartOfHistory = false + setConsultantConfig(nil) + consultantView.setContactFormPresentation(.hidden) + consultantView.setInputBarHidden(false) + consultantView.setCanSendMessages(false) + consultantView.setCanLoadOlderMessages(false) + } + +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/AgreementView.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/AgreementView.swift new file mode 100644 index 0000000..7c02fcb --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/AgreementView.swift @@ -0,0 +1,137 @@ +import UIKit + +/// Displays the personal data processing agreement notice above the input bar. +package final class AgreementView: UIView { + + package static let height: CGFloat = 47 + package static let horizontalInset: CGFloat = 16 + private static let itemSpacing: CGFloat = 8 + private static let iconSize: CGFloat = 24 + package static let backgroundColor = UIColor(hex: 0x636F7F, alpha: 0.20) + private static let initialIconColor = UIColor(hex: 0x005EEB) + package static let linkColor = UIColor(hex: 0x0052CC) + + private lazy var textLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.regularCompactFont + label.numberOfLines = 2 + label.lineBreakMode = .byWordWrapping + label.accessibilityIdentifier = "agreementLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var iconImageView: UIImageView = { + let view = UIImageView() + view.image = ConsultantImageResource.infoIcon.templateImage + view.tintColor = Self.initialIconColor + view.contentMode = .scaleAspectFit + view.accessibilityIdentifier = "agreementIcon" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var heightConstraint: NSLayoutConstraint? + private var link: URL? + + /// Called when the user taps the agreement notice with an available link. + package var onTap: ((URL) -> Void)? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + backgroundColor = Self.backgroundColor + accessibilityIdentifier = "agreementView" + isUserInteractionEnabled = true + + setupSubviews() + setupConstraints() + setupGesture() + } + + private func setupSubviews() { + addSubview(textLabel) + addSubview(iconImageView) + } + + private func setupConstraints() { + let heightConstraint = heightAnchor.constraint(equalToConstant: 0) + self.heightConstraint = heightConstraint + + NSLayoutConstraint.activate([ + heightConstraint, + iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalInset), + iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: Self.iconSize), + iconImageView.heightAnchor.constraint(equalToConstant: Self.iconSize), + + textLabel.leadingAnchor.constraint( + equalTo: iconImageView.trailingAnchor, + constant: Self.itemSpacing + ), + textLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + textLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.horizontalInset) + ]) + } + + private func setupGesture() { + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap))) + } + + /// Applies theme colors and shows the agreement only when a link is available. + package func configure(theme: ConsultantTheme, link: URL?) { + self.link = link + let isVisible = link != nil + isHidden = !isVisible + heightConstraint?.constant = isVisible ? Self.height : 0 + iconImageView.tintColor = Self.linkColor + textLabel.attributedText = Self.makeText( + textColor: theme.primaryTextColor, + linkColor: Self.linkColor + ) + } + + @objc + private func handleTap() { + guard let link else { + return + } + + onTap?(link) + } +} + +private extension AgreementView { + + static func makeText(textColor: UIColor, linkColor: UIColor) -> NSAttributedString { + let firstPart = ConsultantLocalization.messageAgreementFirst.localized + let linkPart = ConsultantLocalization.messageAgreementLink.localized + let thirdPart = ConsultantLocalization.messageAgreementThird.localized + let text = firstPart + linkPart + thirdPart + let attributedText = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: textColor, + .font: MessageBubbleStyle.regularCompactFont + ] + ) + let linkRange = (text as NSString).range(of: linkPart) + if linkRange.location != NSNotFound { + attributedText.addAttributes( + [ + .foregroundColor: linkColor + ], + range: linkRange + ) + } + return attributedText + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantStateView.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantStateView.swift new file mode 100644 index 0000000..5a26e61 --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantStateView.swift @@ -0,0 +1,136 @@ +import UIKit + +package final class ConsultantStateView: UIView { + + package enum Mode: Equatable { + case hidden + case loading + case error + } + + private lazy var activityIndicatorView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.hidesWhenStopped = true + view.accessibilityIdentifier = "consultantStateActivityIndicator" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .headline) + label.textAlignment = .center + label.numberOfLines = 0 + label.accessibilityIdentifier = "consultantStateTitleLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var retryButton: UIButton = { + let button = UIButton(type: .system) + var configuration = UIButton.Configuration.filled() + configuration.background.cornerRadius = 8 + configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 18, bottom: 10, trailing: 18) + configuration.title = ConsultantLocalization.commonRetry.localized + button.configuration = configuration + button.titleLabel?.font = .preferredFont(forTextStyle: .body) + button.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside) + button.accessibilityIdentifier = "consultantStateRetryButton" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var stackView: UIStackView = { + let view = UIStackView(arrangedSubviews: [ + activityIndicatorView, + titleLabel, + retryButton + ]) + view.axis = .vertical + view.alignment = .center + view.spacing = 16 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var mode: Mode = .hidden + private var theme = ConsultantTheme.defaultTheme + + package var onRetry: (() -> Void)? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + package func setMode(_ mode: Mode) { + guard self.mode != mode else { + return + } + + self.mode = mode + applyMode() + } + + package func setTheme(_ theme: ConsultantTheme) { + self.theme = theme + applyTheme() + } + + private func setup() { + accessibilityIdentifier = "consultantStateView" + translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -24), + stackView.centerXAnchor.constraint(equalTo: centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + retryButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) + ]) + + applyTheme() + applyMode() + } + + private func applyTheme() { + backgroundColor = theme.screenBackgroundColor + activityIndicatorView.color = theme.brandColor + titleLabel.textColor = theme.primaryTextColor + var configuration = retryButton.configuration + configuration?.baseBackgroundColor = theme.brandColor + configuration?.baseForegroundColor = theme.mainTextColor + retryButton.configuration = configuration + } + + private func applyMode() { + switch mode { + case .hidden: + isHidden = true + activityIndicatorView.stopAnimating() + titleLabel.isHidden = true + retryButton.isHidden = true + case .loading: + isHidden = false + titleLabel.isHidden = true + retryButton.isHidden = true + activityIndicatorView.startAnimating() + case .error: + isHidden = false + activityIndicatorView.stopAnimating() + titleLabel.text = ConsultantLocalization.commonError.localized + titleLabel.isHidden = false + retryButton.isHidden = false + } + } + + @objc private func retryButtonTapped() { + onRetry?() + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantView.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantView.swift new file mode 100644 index 0000000..069fde0 --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/ConsultantView.swift @@ -0,0 +1,1664 @@ +import UIKit + +/// Root chat view that renders messages, contact forms, the agreement notice, and the input composer. +/// +/// The view owns message list presentation, incremental collection updates, scroll anchoring, keyboard +/// handling, attachment preview loading state, and UI callbacks forwarded to `OnlineConsultantViewController`. + package final class ConsultantView: UIView { + + private static let historyPreloadViewportMultiplier: CGFloat = 3 + private static let historyLoadingBufferViewportMultiplier: CGFloat = 4 + + private lazy var messagesContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var agreementView: AgreementView = { + let view = AgreementView() + view.onTap = { [weak self] url in + self?.onAgreementTap?(url) + } + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var messagesCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumLineSpacing = 2 + layout.minimumInteritemSpacing = 0 + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.alwaysBounceVertical = true + collectionView.keyboardDismissMode = .none + collectionView.backgroundColor = .systemBackground + collectionView.showsVerticalScrollIndicator = true + collectionView.accessibilityIdentifier = "messagesCollectionView" + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register( + MessageCollectionViewCell.self, + forCellWithReuseIdentifier: CellReuseIdentifier.message.rawValue + ) + collectionView.panGestureRecognizer.addTarget( + self, + action: #selector(messagesCollectionViewPanGestureDidChange) + ) + collectionView.translatesAutoresizingMaskIntoConstraints = false + return collectionView + }() + + private lazy var inputBarView: InputBar = { + let view = InputBar() + view.onSend = { [weak self] text in + self?.sendMessage(text) + } + view.onOpenAttachmentMenu = { [weak self] sourceView in + self?.onOpenAttachmentMenu?(sourceView) + } + view.onRemoveInputAttachment = { [weak self] id in + self?.onRemoveInputAttachment?(id) + } + view.onRetryInputAttachment = { [weak self] id in + self?.onRetryInputAttachment?(id) + } + view.onIntrinsicContentSizeChange = { [weak self] in + self?.inputBarIntrinsicContentSizeDidChange() + } + view.addGestureRecognizer(inputBarKeyboardDismissPanGestureRecognizer) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var footerBrandingView: FooterBrandingView = { + let view = FooterBrandingView() + view.isHidden = true + view.onTap = { [weak self] url in + self?.onBrandingFooterTap?(url) + } + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var contactFormView: ContactFormView = { + let view = ContactFormView() + view.isHidden = true + view.onSubmit = { [weak self] data in + self?.onContactFormSubmit?(data) + } + view.onPersonalDataLinkTap = { [weak self] url in + self?.onAgreementTap?(url) + } + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var stateView: ConsultantStateView = { + let view = ConsultantStateView() + view.isHidden = true + return view + }() + + private lazy var inputBarKeyboardDismissPanGestureRecognizer: UIPanGestureRecognizer = { + let gestureRecognizer = UIPanGestureRecognizer( + target: self, + action: #selector(inputBarKeyboardDismissPanGestureDidChange) + ) + gestureRecognizer.cancelsTouchesInView = false + gestureRecognizer.delegate = self + return gestureRecognizer + }() + + private var realMessages: [ConsultantMessage] = [] + private var messages: [ConsultantMessage] = [] + private var messageItemIDs: [MessageItemID] = [] + private var contactFormMessage: ConsultantMessage? + private var fullscreenContactForm: ContactForm? + private var isContactFormSubmitting = false + private var canSendMessages = false + private var inputAttachments: [InputAttachment] = [] + private var loadingFilePreviewIDs: Set = [] + private var loadingImagePreviewIDs: Set = [] + private var isLoadingOlderMessages = false + private var canLoadOlderMessages = true + private var historyLoadingSpacerHeight: CGFloat = 0 + private var shouldKeepMessagesPinnedToBottom = false + private var isScrollToBottomAfterLayoutScheduled = false + private var isVisibleReadCheckScheduled = false + private var consultantConfig: ConsultantConfig? + private var theme = ConsultantTheme.defaultTheme + private var managerIdentity = ManagerIdentity(config: nil) + private var inputBarBottomConstraint: NSLayoutConstraint? + private var messagesBottomToInputBarConstraint: NSLayoutConstraint? + private var messagesBottomToFooterConstraint: NSLayoutConstraint? + private var footerBrandingHeightConstraint: NSLayoutConstraint? + private var isInputBarHidden = false + private var isObservingKeyboard = false + private var lastVisibleReadMessageID: String? + + /// Called when the visitor submits contact form data from the fullscreen form or a message bubble. + package var onContactFormSubmit: ((ContactFormSubmitData) -> Void)? + + package var onSendMessage: ((String) -> Void)? + package var onRetryMessage: ((String) -> Void)? + package var onOpenAttachmentMenu: ((UIView) -> Void)? + package var onRemoveInputAttachment: ((UUID) -> Void)? + package var onRetryInputAttachment: ((UUID) -> Void)? + package var onQuoteTap: ((String) -> Void)? + package var onLoadOlderMessages: (() -> Void)? + package var onImagePreview: (([ImagePreviewItem], Int) -> Void)? + package var onFilePreview: (([FilePreviewItem], Int) -> Void)? + package var onMessageLinkTap: ((URL) -> Void)? + package var onAgreementTap: ((URL) -> Void)? + package var onBrandingFooterTap: ((URL) -> Void)? + package var onVisibleIncomingMessagesRead: ((String) -> Bool)? + package var onStateRetry: (() -> Void)? { + get { + stateView.onRetry + } + set { + stateView.onRetry = newValue + } + } + + /// Creates the root chat view programmatically. + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + /// Creates the root chat view from a storyboard or nib. + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + deinit { + quoteScrollDisplayLink?.invalidate() + unregisterForKeyboardNotifications() + } + + /// Starts keyboard observation when the view enters a window and stops it when the view is removed. + package override func didMoveToWindow() { + super.didMoveToWindow() + + if window == nil { + stopQuoteScrollAnimation() + unregisterForKeyboardNotifications() + inputBarBottomConstraint?.constant = 0 + } else { + registerForKeyboardNotifications() + scheduleVisibleIncomingMessagesReadCheck() + } + } + + /// Updates message list insets and keeps the list pinned to the bottom after layout changes. + package override func layoutSubviews() { + super.layoutSubviews() + updateMessagesContentInset() + pinMessagesToBottomIfNeeded() + scheduleScrollToBottomAfterLayoutIfNeeded() + markVisibleIncomingMessagesReadIfNeeded() + } + + private func setup() { + setupSubviews() + setupConstraints() + applyTheme() + } + + private func setupSubviews() { + addSubview(agreementView) + addSubview(messagesContainerView) + addSubview(inputBarView) + addSubview(footerBrandingView) + addSubview(contactFormView) + addSubview(stateView) + messagesContainerView.addSubview(messagesCollectionView) + updateSendMessageControls() + } + + private func setupConstraints() { + let inputBarBottomConstraint = footerBrandingView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor) + let footerBrandingHeightConstraint = footerBrandingView.heightAnchor.constraint(equalToConstant: 0) + let messagesBottomToInputBarConstraint = messagesContainerView.bottomAnchor.constraint(equalTo: inputBarView.topAnchor) + let messagesBottomToFooterConstraint = messagesContainerView.bottomAnchor.constraint(equalTo: footerBrandingView.topAnchor) + self.inputBarBottomConstraint = inputBarBottomConstraint + self.footerBrandingHeightConstraint = footerBrandingHeightConstraint + self.messagesBottomToInputBarConstraint = messagesBottomToInputBarConstraint + self.messagesBottomToFooterConstraint = messagesBottomToFooterConstraint + + NSLayoutConstraint.activate([ + agreementView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + agreementView.leadingAnchor.constraint(equalTo: leadingAnchor), + agreementView.trailingAnchor.constraint(equalTo: trailingAnchor), + + messagesContainerView.topAnchor.constraint(equalTo: agreementView.bottomAnchor), + messagesContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), + messagesContainerView.trailingAnchor.constraint(equalTo: trailingAnchor), + messagesBottomToInputBarConstraint, + + messagesCollectionView.topAnchor.constraint(equalTo: messagesContainerView.topAnchor), + messagesCollectionView.leadingAnchor.constraint(equalTo: messagesContainerView.leadingAnchor), + messagesCollectionView.trailingAnchor.constraint(equalTo: messagesContainerView.trailingAnchor), + messagesCollectionView.bottomAnchor.constraint(equalTo: messagesContainerView.bottomAnchor), + + inputBarView.leadingAnchor.constraint(equalTo: leadingAnchor), + inputBarView.trailingAnchor.constraint(equalTo: trailingAnchor), + inputBarView.bottomAnchor.constraint(equalTo: footerBrandingView.topAnchor), + + footerBrandingView.leadingAnchor.constraint(equalTo: leadingAnchor), + footerBrandingView.trailingAnchor.constraint(equalTo: trailingAnchor), + inputBarBottomConstraint, + footerBrandingHeightConstraint, + + contactFormView.topAnchor.constraint(equalTo: agreementView.bottomAnchor), + contactFormView.leadingAnchor.constraint(equalTo: leadingAnchor), + contactFormView.trailingAnchor.constraint(equalTo: trailingAnchor), + contactFormView.bottomAnchor.constraint(equalTo: bottomAnchor), + + stateView.topAnchor.constraint(equalTo: topAnchor), + stateView.leadingAnchor.constraint(equalTo: leadingAnchor), + stateView.trailingAnchor.constraint(equalTo: trailingAnchor), + stateView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + /// Shows, hides, or embeds the configured contact form according to the current presentation mode. + package func setContactFormPresentation(_ presentation: ContactFormPresentation) { + contactFormView.setSubmitting(false) + isContactFormSubmitting = false + + switch presentation { + case .hidden, .waitingForFirstMessage: + contactFormView.isHidden = true + fullscreenContactForm = nil + contactFormMessage = nil + case .visible(let form): + if form.display == .atBeginningDialogue { + fullscreenContactForm = form + contactFormView.setPersonalDataLink(consultantConfig?.personalDataLink) + contactFormView.configure(form: form) + contactFormView.isHidden = false + bringSubviewToFront(contactFormView) + contactFormMessage = nil + } else { + contactFormView.isHidden = true + fullscreenContactForm = nil + contactFormMessage = Self.makeContactFormMessage(form) + } + } + + updateAgreementView() + applyDisplayedMessages() + } + + /// Updates contact form submitting state in both fullscreen and message-bubble presentations. + package func setContactFormSubmitting(_ isSubmitting: Bool) { + isContactFormSubmitting = isSubmitting + if isSubmitting { + contactFormView.setSubmitError(nil) + } + contactFormView.setSubmitting(isSubmitting) + messagesCollectionView.visibleCells + .compactMap { $0 as? MessageCollectionViewCell } + .forEach { $0.setContactFormSubmitting(isSubmitting) } + } + + /// Displays or clears the contact form submit error shown by the fullscreen form. + package func setContactFormSubmitError(_ message: String?) { + contactFormView.setSubmitError(message) + } + + package func setCanSendMessages(_ canSend: Bool) { + guard canSendMessages != canSend else { + return + } + + canSendMessages = canSend + updateSendMessageControls() + } + + package func setInputBarHidden(_ isHidden: Bool) { + guard isInputBarHidden != isHidden else { + return + } + + isInputBarHidden = isHidden + inputBarView.isHidden = isHidden + inputBarView.endEditing(true) + messagesBottomToInputBarConstraint?.isActive = !isHidden + messagesBottomToFooterConstraint?.isActive = isHidden + + setNeedsLayout() + inputBarIntrinsicContentSizeDidChange() + } + + package func clearMessageInput() { + inputBarView.clearText() + } + + package func setStateViewMode(_ mode: ConsultantStateView.Mode) { + stateView.setMode(mode) + if mode != .hidden { + bringSubviewToFront(stateView) + } + } + + package func setInputAttachments(_ attachments: [InputAttachment]) { + inputAttachments = attachments + inputBarView.setInputAttachments(attachments) + } + + package func setLoadingFilePreviewIDs(_ fileIDs: Set) { + guard loadingFilePreviewIDs != fileIDs else { + return + } + + loadingFilePreviewIDs = fileIDs + reconfigureVisibleMessageCells() + } + + package func setLoadingImagePreviewIDs(_ fileIDs: Set) { + guard loadingImagePreviewIDs != fileIDs else { + return + } + + loadingImagePreviewIDs = fileIDs + reconfigureVisibleMessageCells() + } + + package func setLoadingOlderMessages(_ isLoading: Bool) { + guard isLoadingOlderMessages != isLoading else { + return + } + + isLoadingOlderMessages = isLoading + } + + package func setCanLoadOlderMessages(_ canLoad: Bool) { + canLoadOlderMessages = canLoad + } + + /// Applies backend-provided consultant configuration to theme, manager identity, agreement, and messages. + package func setConsultantConfig(_ config: ConsultantConfig?) { + guard consultantConfig != config else { + return + } + + consultantConfig = config + theme = ConsultantTheme(config: config) + managerIdentity = ManagerIdentity(config: config) + applyTheme() + inputBarView.setTheme(theme) + contactFormView.setTheme(theme) + stateView.setTheme(theme) + contactFormView.setPersonalDataLink(consultantConfig?.personalDataLink) + messagesCollectionView.reloadData() + updateAgreementView() + } + + /// Replaces the backend message list and refreshes the displayed chat timeline. + package func setMessages(_ messages: [ConsultantMessage]) { + realMessages = messages + lastVisibleReadMessageID = nil + updateAgreementView() + applyDisplayedMessages() + } + + private var pendingQuoteMessageID: String? + private var scrollingToQuoteMessageID: String? + private var quoteScrollDisplayLink: CADisplayLink? + private var quoteScrollStartTime: CFTimeInterval = 0 + private var quoteScrollStartY: CGFloat = 0 + private var quoteScrollTargetY: CGFloat = 0 + private var messageListUpdatesInFlight = 0 + + /// Waits for collection updates before scrolling and highlighting the requested message. + @discardableResult + package func revealMessage(id: String) -> Bool { + guard messages.contains(where: { $0.id?.value == id && $0.status != .deleted }) else { return false } + stopQuoteScrollAnimation() + shouldKeepMessagesPinnedToBottom = false + pendingQuoteMessageID = id + revealPendingQuoteIfNeeded() + return true + } + + private func revealPendingQuoteIfNeeded() { + guard messageListUpdatesInFlight == 0, let id = pendingQuoteMessageID, + let index = messages.firstIndex(where: { $0.id?.value == id }) else { return } + pendingQuoteMessageID = nil + shouldKeepMessagesPinnedToBottom = false + messagesCollectionView.layoutIfNeeded() + let indexPath = IndexPath(item: index, section: 0) + guard let attributes = messagesCollectionView.layoutAttributesForItem(at: indexPath) else { return } + let insets = messagesCollectionView.adjustedContentInset + let visibleHeight = messagesCollectionView.bounds.height - insets.top - insets.bottom + let targetY = clampedContentOffsetY(attributes.frame.midY - insets.top - visibleHeight / 2) + scrollingToQuoteMessageID = id + if abs(messagesCollectionView.contentOffset.y - targetY) < 1 || UIAccessibility.isReduceMotionEnabled { + messagesCollectionView.setContentOffset(CGPoint(x: messagesCollectionView.contentOffset.x, y: targetY), animated: false) + finishQuoteNavigation() + } else { + quoteScrollStartY = messagesCollectionView.contentOffset.y + quoteScrollTargetY = targetY + quoteScrollStartTime = CACurrentMediaTime() + let target = QuoteScrollAnimationTarget(view: self) + let link = CADisplayLink(target: target, selector: #selector(QuoteScrollAnimationTarget.tick(_:))) + quoteScrollDisplayLink = link + link.add(to: .main, forMode: .common) + } + } + + package func advanceQuoteScrollAnimation(_ timestamp: CFTimeInterval) { + let progress = min(1, max(0, (timestamp - quoteScrollStartTime) / 0.3)) + let easedProgress = CGFloat(1 - pow(1 - progress, 3)) + let y = quoteScrollStartY + (quoteScrollTargetY - quoteScrollStartY) * easedProgress + messagesCollectionView.setContentOffset(CGPoint(x: messagesCollectionView.contentOffset.x, y: y), animated: false) + if progress >= 1 { + quoteScrollDisplayLink?.invalidate() + quoteScrollDisplayLink = nil + finishQuoteNavigation() + } + } + + private func stopQuoteScrollAnimation() { + quoteScrollDisplayLink?.invalidate() + quoteScrollDisplayLink = nil + scrollingToQuoteMessageID = nil + } + + private func finishQuoteNavigation() { + guard let id = scrollingToQuoteMessageID, + let index = messages.firstIndex(where: { $0.id?.value == id }) else { return } + scrollingToQuoteMessageID = nil + messagesCollectionView.layoutIfNeeded() + let indexPath = IndexPath(item: index, section: 0) + (messagesCollectionView.cellForItem(at: indexPath) as? MessageCollectionViewCell)?.highlightQuotedMessage() + scheduleVisibleIncomingMessagesReadCheck() + } + + private func applyDisplayedMessages() { + let oldMessages = self.messages + let oldItemIDs = messageItemIDs + let newMessages = realMessages.sortedForChatDisplay() + + [contactFormMessage].compactMap { $0 } + let newItemIDs = Self.makeItemIDs(for: newMessages) + let shouldScrollToBottom = scrollingToQuoteMessageID == nil + && (oldMessages.isEmpty || shouldKeepMessagesPinnedToBottom || isScrolledNearBottom()) + + guard messagesCollectionView.bounds.width > 0, + messagesCollectionView.bounds.height > 0 else { + messages = newMessages + messageItemIDs = newItemIDs + updateMessagesBottomPinning(shouldScrollToBottom) + messagesCollectionView.reloadData() + setNeedsLayout() + return + } + + if !shouldScrollToBottom, + !oldMessages.isEmpty, + applyPrependedMessages( + oldMessages: oldMessages, + oldItemIDs: oldItemIDs, + newMessages: newMessages, + newItemIDs: newItemIDs + ) { + return + } + + let update = makeMessageListUpdate( + oldMessages: oldMessages, + oldItemIDs: oldItemIDs, + newMessages: newMessages, + newItemIDs: newItemIDs + ) + + self.messages = newMessages + messageItemIDs = newItemIDs + + apply(update: update, shouldScrollToBottom: shouldScrollToBottom) + } + + private func applyPrependedMessages( + oldMessages: [ConsultantMessage], + oldItemIDs: [MessageItemID], + newMessages: [ConsultantMessage], + newItemIDs: [MessageItemID] + ) -> Bool { + guard + newItemIDs.count > oldItemIDs.count, + Array(newItemIDs.suffix(oldItemIDs.count)) == oldItemIDs + else { + return false + } + + let historyLoadingBufferHeight = messagesCollectionView.bounds.height + * Self.historyLoadingBufferViewportMultiplier + messagesCollectionView.layoutIfNeeded() + let oldContentHeight = messagesCollectionView.collectionViewLayout.collectionViewContentSize.height + let oldContentOffsetY = messagesCollectionView.contentOffset.y + let insertedCount = newItemIDs.count - oldItemIDs.count + let insertedIndexPaths = (0..= insertedCount } + + // The first old cell can change grouping after prepending older messages. + if oldItemIDs.indices.contains(0) { + let firstOldIndexPath = IndexPath(item: insertedCount, section: 0) + if !reloadedIndexPaths.contains(firstOldIndexPath) { + reloadedIndexPaths.append(firstOldIndexPath) + } + } + + messages = newMessages + messageItemIDs = newItemIDs + + messageListUpdatesInFlight += 1 + UIView.performWithoutAnimation { + CATransaction.begin() + CATransaction.setDisableActions(true) + setHistoryLoadingSpacerHeight(historyLoadingBufferHeight) + messagesCollectionView.performBatchUpdates { + messagesCollectionView.insertItems(at: insertedIndexPaths) + if !reloadedIndexPaths.isEmpty { + messagesCollectionView.reloadItems(at: reloadedIndexPaths) + } + } completion: { [weak self] _ in + guard let self else { return } + + self.messagesCollectionView.layoutIfNeeded() + let newContentHeight = self.messagesCollectionView.collectionViewLayout.collectionViewContentSize.height + let contentHeightIncrease = max(0, newContentHeight - oldContentHeight) + self.setHistoryLoadingSpacerHeight(0) + self.messagesCollectionView.setContentOffset( + CGPoint( + x: self.messagesCollectionView.contentOffset.x, + y: self.clampedContentOffsetY(oldContentOffsetY + contentHeightIncrease) + ), + animated: false + ) + self.messagesCollectionView.layoutIfNeeded() + self.messageListUpdatesInFlight -= 1 + self.revealPendingQuoteIfNeeded() + } + CATransaction.commit() + } + + return true + } + + private func makeMessageListUpdate( + oldMessages: [ConsultantMessage], + oldItemIDs: [MessageItemID], + newMessages: [ConsultantMessage], + newItemIDs: [MessageItemID] + ) -> MessageListUpdate { + guard !oldMessages.isEmpty else { + return .reloadData + } + + if newItemIDs.count > oldItemIDs.count, + Array(newItemIDs.prefix(oldItemIDs.count)) == oldItemIDs { + let insertedRange = oldItemIDs.count.. 0, bounds.height > 0 else { return } + + updateMessagesBottomPinning(isScrolledNearBottom()) + + UIView.animate(withDuration: 0.2) { + self.layoutIfNeeded() + } completion: { [weak self] _ in + guard let self else { return } + + self.updateMessagesContentInset() + self.scrollToBottomIfPinned() + self.scheduleScrollToBottomAfterLayoutIfNeeded() + } + } + + private func applyTheme() { + backgroundColor = theme.screenBackgroundColor + messagesContainerView.backgroundColor = theme.screenBackgroundColor + messagesCollectionView.backgroundColor = theme.screenBackgroundColor + footerBrandingView.configure(domainConfig: consultantConfig?.domainConfig, theme: theme) + updateFooterBrandingVisibility() + updateAgreementView() + } + + private func updateAgreementView() { + let contactFormWithPersonalData = fullscreenContactForm?.fields[.personalData] != nil + || contactFormMessage?.contactForm?.fields[.personalData] != nil + || realMessages.contains { $0.contactForm?.fields[.personalData] != nil } + agreementView.configure( + theme: theme, + link: consultantConfig?.personalDataConsent == true && !contactFormWithPersonalData + ? consultantConfig?.personalDataLink + : nil + ) + } + + private func updateFooterBrandingVisibility() { + let shouldShowFooterBranding = consultantConfig?.domainConfig?.footer == true + footerBrandingView.isHidden = !shouldShowFooterBranding + footerBrandingHeightConstraint?.constant = shouldShowFooterBranding ? 31 : 0 + setNeedsLayout() + } + + private func registerForKeyboardNotifications() { + guard !isObservingKeyboard else { return } + isObservingKeyboard = true + + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + private func unregisterForKeyboardNotifications() { + guard isObservingKeyboard else { return } + isObservingKeyboard = false + + NotificationCenter.default.removeObserver( + self, + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.removeObserver( + self, + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard + window != nil, + let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { + return + } + + // Hidden keyboards can report an empty frame while the context menu transforms the window. + // There is no overlap to convert in that case. + guard !endFrame.isEmpty, !endFrame.isNull, !endFrame.isInfinite else { + updateInputBarBottomOffset(0, notification: notification) + return + } + + let keyboardFrame = convert(endFrame, from: nil) + let keyboardOverlap = max(0, bounds.maxY - keyboardFrame.minY) + let bottomOffset = max(0, keyboardOverlap - safeAreaInsets.bottom) + updateInputBarBottomOffset(bottomOffset, notification: notification) + } + + @objc private func keyboardWillHide(_ notification: Notification) { + updateInputBarBottomOffset(0, notification: notification) + } + + private func updateInputBarBottomOffset(_ offset: CGFloat, notification: Notification) { + updateMessagesBottomPinning(isScrolledNearBottom()) + inputBarBottomConstraint?.constant = -offset + animateKeyboardLayout(notification: notification) { + self.layoutIfNeeded() + } completion: { [weak self] in + guard let self else { return } + + self.updateMessagesContentInset() + self.scrollToBottomIfPinned() + self.scheduleScrollToBottomAfterLayoutIfNeeded() + } + } + + private func animateKeyboardLayout( + notification: Notification, + animations: @escaping () -> Void, + completion: (() -> Void)? = nil + ) { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 + let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0 + let options = UIView.AnimationOptions(rawValue: UInt(curve << 16)) + + UIView.animate( + withDuration: duration, + delay: 0, + options: options, + animations: animations, + completion: { _ in completion?() } + ) + } + + private func isScrolledNearBottom() -> Bool { + let topOffset = -messagesCollectionView.adjustedContentInset.top + let bottomOffset = max( + topOffset, + messagesCollectionView.collectionViewLayout.collectionViewContentSize.height + - messagesCollectionView.bounds.height + + messagesCollectionView.adjustedContentInset.bottom + ) + return messagesCollectionView.contentOffset.y >= bottomOffset - 80 + } + + private func updateMessagesContentInset() { + let contentHeight = messagesCollectionView.collectionViewLayout.collectionViewContentSize.height + let topInset = max(0, messagesCollectionView.bounds.height - contentHeight) + + historyLoadingSpacerHeight + guard abs(messagesCollectionView.contentInset.top - topInset) > 0.5 else { + return + } + + messagesCollectionView.contentInset.top = topInset + messagesCollectionView.verticalScrollIndicatorInsets.top = topInset + } + + private func setHistoryLoadingSpacerHeight(_ height: CGFloat) { + guard abs(historyLoadingSpacerHeight - height) > 0.5 else { + return + } + + historyLoadingSpacerHeight = height + + UIView.performWithoutAnimation { + updateMessagesContentInset() + messagesCollectionView.layoutIfNeeded() + } + } + + private func updateMessagesBottomPinning(_ shouldPin: Bool) { + // Preserve pinning across overlapping updates; a manual scroll cancels it. + shouldKeepMessagesPinnedToBottom = (shouldKeepMessagesPinnedToBottom || shouldPin) + && !messagesCollectionView.isDragging + && !messagesCollectionView.isDecelerating + } + + private func scheduleScrollToBottomAfterLayoutIfNeeded() { + guard + shouldKeepMessagesPinnedToBottom, + !isScrollToBottomAfterLayoutScheduled + else { + return + } + + isScrollToBottomAfterLayoutScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.isScrollToBottomAfterLayoutScheduled = false + self.scrollToBottomIfPinned() + self.scheduleVisibleIncomingMessagesReadCheck() + } + } + + package func scheduleVisibleIncomingMessagesReadCheck() { + guard !isVisibleReadCheckScheduled else { + return + } + + isVisibleReadCheckScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + self.isVisibleReadCheckScheduled = false + guard self.window != nil, + self.messagesCollectionView.bounds.width > 0, + self.messagesCollectionView.bounds.height > 0 else { return } + self.messagesCollectionView.layoutIfNeeded() + self.markVisibleIncomingMessagesReadIfNeeded() + } + } + + private func scrollToBottomIfPinned() { + guard + shouldKeepMessagesPinnedToBottom, + !messages.isEmpty, + messagesCollectionView.bounds.width > 0, + messagesCollectionView.bounds.height > 0 + else { + return + } + + scrollToBottom() + } + + private func scrollToBottom() { + guard !messages.isEmpty else { + return + } + + messagesCollectionView.layoutIfNeeded() + updateMessagesContentInset() + messagesCollectionView.collectionViewLayout.invalidateLayout() + messagesCollectionView.layoutIfNeeded() + + UIView.performWithoutAnimation { + setMessagesContentOffsetToBottom() + messagesCollectionView.layoutIfNeeded() + } + } + + private func pinMessagesToBottomIfNeeded() { + guard + shouldKeepMessagesPinnedToBottom, + !messages.isEmpty, + messagesCollectionView.bounds.width > 0, + messagesCollectionView.bounds.height > 0 + else { + return + } + + UIView.performWithoutAnimation { + setMessagesContentOffsetToBottom() + } + } + + private func setMessagesContentOffsetToBottom() { + let bottomOffsetY = max( + -messagesCollectionView.adjustedContentInset.top, + messagesCollectionView.collectionViewLayout.collectionViewContentSize.height + - messagesCollectionView.bounds.height + + messagesCollectionView.adjustedContentInset.bottom + ) + let contentOffset = CGPoint(x: messagesCollectionView.contentOffset.x, y: bottomOffsetY) + messagesCollectionView.setContentOffset(contentOffset, animated: false) + } + + private func requestOlderMessagesIfNeeded(_ scrollView: UIScrollView) { + guard + !isLoadingOlderMessages, + canLoadOlderMessages, + contactFormView.isHidden, + !messages.isEmpty, + scrollView.isDragging || scrollView.isDecelerating, + let onLoadOlderMessages + else { + return + } + + let topOffset = -scrollView.adjustedContentInset.top + let preloadDistance = scrollView.bounds.height * Self.historyPreloadViewportMultiplier + guard scrollView.contentOffset.y <= topOffset + preloadDistance else { + return + } + + setLoadingOlderMessages(true) + onLoadOlderMessages() + } + + package func markVisibleIncomingMessagesReadIfNeeded() { + guard + window != nil, + let messageID = maximumVisibleUnreadIncomingMessageID() + else { + return + } + + guard messageID != lastVisibleReadMessageID else { + return + } + + guard onVisibleIncomingMessagesRead?(messageID) == true else { + return + } + + lastVisibleReadMessageID = messageID + } + + private func maximumVisibleUnreadIncomingMessageID() -> String? { + visibleMessageIndexPaths() + .compactMap { indexPath -> UInt64? in + guard + messages.indices.contains(indexPath.item), + let id = messages[indexPath.item].unreadIncomingBackendMessageID + else { + return nil + } + + return UInt64(id) + } + .max() + .map(String.init) + } + + private func visibleMessageIndexPaths() -> Set { + var indexPaths = Set(messagesCollectionView.indexPathsForVisibleItems) + + guard messagesCollectionView.bounds.width > 0, + messagesCollectionView.bounds.height > 0 else { + return indexPaths + } + + messagesCollectionView.layoutIfNeeded() + let attributes = messagesCollectionView.collectionViewLayout.layoutAttributesForElements( + in: messagesCollectionView.bounds + ) ?? [] + indexPaths.formUnion(attributes.map(\.indexPath)) + + return indexPaths + } + + private func clampedContentOffsetY(_ offsetY: CGFloat) -> CGFloat { + let minOffsetY = -messagesCollectionView.adjustedContentInset.top + let maxOffsetY = max( + minOffsetY, + messagesCollectionView.collectionViewLayout.collectionViewContentSize.height + - messagesCollectionView.bounds.height + + messagesCollectionView.adjustedContentInset.bottom + ) + + return min(max(offsetY, minOffsetY), maxOffsetY) + } + + @objc private func inputBarKeyboardDismissPanGestureDidChange(_ gestureRecognizer: UIPanGestureRecognizer) { + guard gestureRecognizer.state == .changed else { + return + } + + guard gestureRecognizer.translation(in: self).y > 6 else { + return + } + + endEditing(true) + } + + @objc private func messagesCollectionViewPanGestureDidChange(_ gestureRecognizer: UIPanGestureRecognizer) { + guard gestureRecognizer.state == .changed else { + return + } + + guard gestureRecognizer.translation(in: self).y > 6 else { + return + } + + let touchLocation = gestureRecognizer.location(in: self) + guard inputBarView.frame.insetBy(dx: 0, dy: -4).contains(touchLocation) else { + return + } + + endEditing(true) + } +} + +private extension ConsultantView { + + private struct MessageItemID: Hashable { + let base: String + let occurrence: Int + } + + private enum MessageListUpdate { + case reloadData + case reloadItems([IndexPath]) + case reconfigureItems([IndexPath]) + case updateDeliveryStatuses([IndexPath]) + case insertAndReload(inserted: [IndexPath], reloaded: [IndexPath]) + } + + private struct MessagePresentationContext: Equatable { + let dateSeparator: Date? + let showsAvatar: Bool + let showsAuthor: Bool + let roundsAllCorners: Bool + } +} + +private extension ConsultantView { + + private static func makeItemIDs(for messages: [ConsultantMessage]) -> [MessageItemID] { + var occurrences: [String: Int] = [:] + + return messages.enumerated().map { index, message in + let base = itemIDBase(for: message, index: index) + let occurrence = occurrences[base, default: 0] + occurrences[base] = occurrence + 1 + return MessageItemID(base: base, occurrence: occurrence) + } + } + + private static func itemIDBase(for message: ConsultantMessage, index: Int) -> String { + if let id = message.id?.value, !id.isEmpty { + return "backend:\(id)" + } + + return [ + "local", + String(index), + message.time ?? "", + message.updatedAt ?? "", + message.messageType?.rawValue ?? "", + message.content ?? "" + ].joined(separator: ":") + } + + private static func makeContactFormMessage(_ form: ContactForm) -> ConsultantMessage { + ConsultantMessage( + id: FlexibleID("_contact_form"), + content: form.text, + time: nil, + user: MessageUser( + firstName: ConsultantLocalization.messageAuthorConsultant.localized + ), + fromMe: nil, + messageType: .contactForm, + status: .seen, + contactForm: form + ) + } + + private static func reloadIndexPaths( + oldMessages: [ConsultantMessage], + oldItemIDs: [MessageItemID], + newMessages: [ConsultantMessage], + newItemIDs: [MessageItemID] + ) -> [IndexPath] { + guard !oldMessages.isEmpty else { + return [] + } + + var reloadItemIDs = Set() + let oldIndexByID = Dictionary(uniqueKeysWithValues: oldItemIDs.enumerated().map { ($0.element, $0.offset) }) + let newIndexByID = Dictionary(uniqueKeysWithValues: newItemIDs.enumerated().map { ($0.element, $0.offset) }) + let oldItemIDSet = Set(oldItemIDs) + + for (newIndex, itemID) in newItemIDs.enumerated() { + guard let oldIndex = oldIndexByID[itemID] else { + insertReloadNeighbors( + around: newIndex, + itemIDs: newItemIDs, + oldItemIDSet: oldItemIDSet, + into: &reloadItemIDs + ) + continue + } + + if oldMessages[oldIndex] != newMessages[newIndex] + || presentationContext(messages: oldMessages, index: oldIndex) + != presentationContext(messages: newMessages, index: newIndex) { + reloadItemIDs.insert(itemID) + } + } + + for (oldIndex, itemID) in oldItemIDs.enumerated() where newIndexByID[itemID] == nil { + insertReloadNeighbors( + aroundDeletedOldIndex: oldIndex, + newItemIDs: newItemIDs, + oldItemIDSet: oldItemIDSet, + into: &reloadItemIDs + ) + } + + return newItemIDs + .filter { reloadItemIDs.contains($0) } + .compactMap { newIndexByID[$0] } + .map { IndexPath(item: $0, section: 0) } + } + + private static func deliveryStatusOnlyIndexPaths( + oldMessages: [ConsultantMessage], + newMessages: [ConsultantMessage] + ) -> [IndexPath]? { + guard oldMessages.count == newMessages.count else { + return nil + } + + var indexPaths: [IndexPath] = [] + + for index in oldMessages.indices { + guard oldMessages[index] != newMessages[index] else { + continue + } + + guard + presentationContext(messages: oldMessages, index: index) + == presentationContext(messages: newMessages, index: index), + isDeliveryStatusOnlyChange( + oldMessage: oldMessages[index], + newMessage: newMessages[index] + ) + else { + return nil + } + + indexPaths.append(IndexPath(item: index, section: 0)) + } + + return indexPaths + } + + private static func isDeliveryStatusOnlyChange( + oldMessage: ConsultantMessage, + newMessage: ConsultantMessage + ) -> Bool { + guard + oldMessage.id == newMessage.id, + oldMessage.fromMe == true, + oldMessage.status != newMessage.status + else { + return false + } + + return oldMessage.content == newMessage.content + && oldMessage.product == newMessage.product + && oldMessage.order == newMessage.order + && oldMessage.items == newMessage.items + && oldMessage.quoteContent == newMessage.quoteContent + && oldMessage.quoteID == newMessage.quoteID + && oldMessage.quotePreview == newMessage.quotePreview + && oldMessage.time == newMessage.time + && oldMessage.user == newMessage.user + && oldMessage.fromMe == newMessage.fromMe + && oldMessage.messageType == newMessage.messageType + && oldMessage.contactForm == newMessage.contactForm + } + + private static func samePositionReloadIndexPaths( + oldMessages: [ConsultantMessage], + oldItemIDs: [MessageItemID], + newMessages: [ConsultantMessage], + newItemIDs: [MessageItemID] + ) -> [IndexPath]? { + guard + oldMessages.count == newMessages.count, + oldItemIDs.count == newItemIDs.count + else { + return nil + } + + var reloadedIndices = Set() + + for index in oldMessages.indices { + let itemIDChanged = oldItemIDs[index] != newItemIDs[index] + let contentChanged = oldMessages[index] != newMessages[index] + || presentationContext(messages: oldMessages, index: index) + != presentationContext(messages: newMessages, index: index) + + if itemIDChanged { + guard isLocalMessageReplacement( + oldMessage: oldMessages[index], + newMessage: newMessages[index] + ) else { + return nil + } + + reloadedIndices.insert(index) + } else if contentChanged { + reloadedIndices.insert(index) + } + } + + return reloadedIndices + .sorted() + .map { IndexPath(item: $0, section: 0) } + } + + private static func isLocalMessageReplacement( + oldMessage: ConsultantMessage, + newMessage: ConsultantMessage + ) -> Bool { + guard + oldMessage.id?.value.hasPrefix("_local_") == true, + newMessage.id?.value.hasPrefix("_local_") != true, + oldMessage.fromMe == true, + newMessage.fromMe == true, + oldMessage.messageType == newMessage.messageType, + oldMessage.content == newMessage.content + else { + return false + } + + return oldMessage.status == .sending + || oldMessage.status == .sent + || oldMessage.status == .failed + } + + private static func insertReloadNeighbors( + around index: Int, + itemIDs: [MessageItemID], + oldItemIDSet: Set, + into reloadItemIDs: inout Set + ) { + [index - 1, index + 1] + .filter { itemIDs.indices.contains($0) } + .map { itemIDs[$0] } + .filter { oldItemIDSet.contains($0) } + .forEach { reloadItemIDs.insert($0) } + } + + private static func insertReloadNeighbors( + aroundDeletedOldIndex oldIndex: Int, + newItemIDs: [MessageItemID], + oldItemIDSet: Set, + into reloadItemIDs: inout Set + ) { + [oldIndex - 1, oldIndex] + .filter { newItemIDs.indices.contains($0) } + .map { newItemIDs[$0] } + .filter { oldItemIDSet.contains($0) } + .forEach { reloadItemIDs.insert($0) } + } + + private static func presentationContext( + messages: [ConsultantMessage], + index: Int + ) -> MessagePresentationContext { + MessagePresentationContext( + dateSeparator: MessageDateSeparator.date(messages: messages, index: index), + showsAvatar: MessageGrouping.showsAvatar( + messages: messages, + index: index + ), + showsAuthor: MessageGrouping.showsAuthor( + messages: messages, + index: index + ), + roundsAllCorners: MessageGrouping.roundsAllCorners( + messages: messages, + index: index + ) + ) + } +} + +extension ConsultantView: UICollectionViewDataSource { + + /// Returns the number of message items currently displayed in the chat timeline. + package func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + messages.count + } + + /// Dequeues and configures a message cell for the requested timeline item. + package func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard + messages.indices.contains(indexPath.item), + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: CellReuseIdentifier.message.rawValue, + for: indexPath + ) as? MessageCollectionViewCell + else { + return UICollectionViewCell() + } + + configure(cell, at: indexPath) + return cell + } +} + +extension ConsultantView: UIGestureRecognizerDelegate { + + /// Allows the input-bar dismissal pan gesture to work together with collection view scrolling. + package func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === inputBarKeyboardDismissPanGestureRecognizer + } +} + +extension ConsultantView: UICollectionViewDelegateFlowLayout { + + /// Offers copying and retrying failed outgoing messages, including attachment-only messages. + package func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + guard messages.indices.contains(indexPath.item) else { return nil } + let message = messages[indexPath.item] + guard message.clipboardText != nil || message.canRetrySending else { return nil } + + return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { [weak self] _ in + guard let self else { return nil } + var actions: [UIAction] = [] + if let text = message.clipboardText { + actions.append(UIAction( + title: ConsultantLocalization.messageActionCopy.localized, + image: UIImage(systemName: "doc.on.doc") + ) { _ in + UIPasteboard.general.string = text + }) + } + if message.canRetrySending, let id = message.id?.value { + actions.append(UIAction( + title: ConsultantLocalization.messageActionRetry.localized, + image: UIImage(systemName: "arrow.clockwise"), + attributes: self.canSendMessages ? [] : .disabled + ) { [weak self] _ in + self?.onRetryMessage?(id) + }) + } + return UIMenu(children: actions) + } + } + + package func collectionView( + _ collectionView: UICollectionView, + previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + contextMenuPreview(in: collectionView, configuration: configuration) + } + + package func collectionView( + _ collectionView: UICollectionView, + previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + contextMenuPreview(in: collectionView, configuration: configuration) + } + + private func contextMenuPreview( + in collectionView: UICollectionView, + configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + guard + let indexPath = configuration.identifier as? IndexPath, + let cell = collectionView.cellForItem(at: indexPath) as? MessageCollectionViewCell + else { + return nil + } + return cell.contextMenuPreview() + } + + package func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + shouldKeepMessagesPinnedToBottom = false + pendingQuoteMessageID = nil + stopQuoteScrollAnimation() + } + + package func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + finishQuoteNavigation() + } + + /// Tracks scroll position and requests older messages when the visitor scrolls near the top. + package func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView.isDragging || scrollView.isDecelerating { + shouldKeepMessagesPinnedToBottom = false + } + + requestOlderMessagesIfNeeded(scrollView) + markVisibleIncomingMessagesReadIfNeeded() + } + + package func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + shouldKeepMessagesPinnedToBottom = isScrolledNearBottom() + } + } + + package func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + shouldKeepMessagesPinnedToBottom = isScrolledNearBottom() + } + + /// Returns the measured size for a message cell at the current collection width. + package func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let width = collectionView.bounds.width + guard width > 0 else { + return CGSize(width: 1, height: 52) + } + + return CGSize( + width: width, + height: (MessageDateSeparator.date(messages: messages, index: indexPath.item) == nil + ? 0 : MessageCollectionViewCell.dateSeparatorHeight) + MessageCollectionViewCell.preferredHeight( + for: messages[indexPath.item], + collectionWidth: width, + showsAuthor: MessageGrouping.showsAuthor( + messages: messages, + index: indexPath.item + ) + ) + ) + } +} + +private extension ConsultantMessage { + + var unreadIncomingBackendMessageID: String? { + guard + fromMe == false, + status != .seen, + status != .deleted, + !isIntroMessage, + let id = id?.value, + UInt64(id) != nil + else { + return nil + } + + return id + } +} + +/// CADisplayLink retains its target; the weak view reference allows the chat to deallocate mid-scroll. +@MainActor +private final class QuoteScrollAnimationTarget: NSObject { + weak var view: ConsultantView? + + init(view: ConsultantView) { self.view = view } + + @objc func tick(_ link: CADisplayLink) { + view?.advanceQuoteScrollAnimation(link.timestamp) + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormContentView.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormContentView.swift new file mode 100644 index 0000000..7f41d8d --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormContentView.swift @@ -0,0 +1,909 @@ +import UIKit + +/// Renders the shared contact form content used by the full-screen form and chat message bubble. +/// +/// The view builds visible text fields from `ConsultantContactForm.fields`, applies user prefill +/// values, handles personal-data and marketing consent toggles, validates required fields, and +/// emits `ContactFormSubmitData` when the visitor submits the form. +package final class ContactFormContentView: UIView { + + package static let fieldHeight: CGFloat = 44 + private static let buttonHeight: CGFloat = 48 + private static let disabledButtonColor = UIColor(red: 0.87, green: 0.89, blue: 0.90, alpha: 1.0) + private static let disabledButtonTextColor = UIColor(red: 0.54, green: 0.59, blue: 0.65, alpha: 1.0) + private static let avatarSize: CGFloat = 64 + private static let avatarBackgroundSize: CGFloat = 56 + private static let avatarBackgroundCornerRadius: CGFloat = 28 + private static let fullScreenContentSpacing: CGFloat = 32 + private static let messageContentSpacing: CGFloat = 12 + private static let fullScreenFormSpacing: CGFloat = 32 + private static let messageFormSpacing: CGFloat = 12 + private static let fieldsSpacing: CGFloat = 12 + private static let checkboxSize: CGFloat = 18 + private static let checkboxTextSpacing: CGFloat = 6 + private static let errorColor = UIColor(red: 1.0, green: 0.33, blue: 0.33, alpha: 1.0) + private static let standardContactFieldKeys: [ContactFormFieldKey] = [.name, .phone, .email] + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var avatarContainerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.accessibilityIdentifier = "contactFormAvatarContainerView" + return view + }() + + private lazy var avatarBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.layer.cornerRadius = Self.avatarBackgroundCornerRadius + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var avatarImageView: UIImageView = { + let view = UIImageView() + view.image = ConsultantImageResource.contactAvatar.image + ?? ConsultantImageResource.person.image + view.contentMode = .scaleAspectFit + view.accessibilityIdentifier = "contactFormAvatarImageView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var messageLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.messageFont + label.textAlignment = .center + label.numberOfLines = 0 + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "contactFormMessageLabel" + return label + }() + + private lazy var formStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + return view + }() + + private lazy var fieldsStackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.spacing = Self.fieldsSpacing + return view + }() + + private lazy var personalDataConsentControl: UIControl = { + let control = UIControl() + control.isHidden = true + control.addTarget( + self, + action: #selector(togglePersonalDataConsent), + for: .touchUpInside + ) + control.accessibilityIdentifier = "contactFormPersonalDataControl" + return control + }() + + private lazy var personalDataCheckboxButton: UIButton = { + let button = UIButton(type: .system) + button.addTarget( + self, + action: #selector(togglePersonalDataConsent), + for: .touchUpInside + ) + button.accessibilityIdentifier = "contactFormPersonalDataCheckbox" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var personalDataConsentTextView: UITextView = { + let textView = UITextView() + textView.backgroundColor = .clear + textView.delegate = self + textView.font = MessageBubbleStyle.regularSmallFont + textView.isEditable = false + textView.isScrollEnabled = false + textView.isSelectable = true + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.attributedText = makePersonalDataConsentText() + textView.linkTextAttributes = [ + .foregroundColor: UIColor.systemBlue + ] + textView.setContentCompressionResistancePriority(.required, for: .vertical) + textView.accessibilityIdentifier = "contactFormPersonalDataLabel" + textView.translatesAutoresizingMaskIntoConstraints = false + return textView + }() + + private lazy var marketingConsentControl: UIControl = { + let control = UIControl() + control.isHidden = true + control.addTarget( + self, + action: #selector(toggleMarketingConsent), + for: .touchUpInside + ) + control.accessibilityIdentifier = "contactFormMarketingControl" + return control + }() + + private lazy var marketingConsentCheckboxButton: UIButton = { + let button = UIButton(type: .system) + button.addTarget( + self, + action: #selector(toggleMarketingConsent), + for: .touchUpInside + ) + button.accessibilityIdentifier = "contactFormMarketingCheckbox" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var marketingConsentLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.font = MessageBubbleStyle.regularSmallFont + label.text = ConsultantLocalization.contactFormMarketingConsent.localized + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.accessibilityIdentifier = "contactFormMarketingLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var errorLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.regularSmallFont + label.numberOfLines = 0 + label.isHidden = true + label.accessibilityIdentifier = "contactFormErrorLabel" + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var resultLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.messageFont + label.numberOfLines = 0 + label.isHidden = true + label.accessibilityIdentifier = "contactFormResultLabel" + return label + }() + + private lazy var submitButton: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = MessageBubbleStyle.semiboldLargeFont + button.setTitle(ConsultantLocalization.contactFormSubmit.localized, for: .normal) + button.setTitleColor(Self.disabledButtonTextColor, for: .disabled) + button.isEnabled = false + button.addTarget( + self, + action: #selector(submitContactForm), + for: .touchUpInside + ) + button.accessibilityIdentifier = "contactFormSubmitButton" + return button + }() + + private lazy var submitActivityIndicator: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.hidesWhenStopped = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + package var onSubmit: ((ContactFormSubmitData) -> Void)? + package var onPersonalDataLinkTap: ((URL) -> Void)? + + private var fieldTextFields: [ContactFormFieldKey: UITextField] = [:] + private var requiredFieldKeys: Set = [] + private var requiresPersonalDataConsent = false + private var isPersonalDataConsentAccepted = false + private var hasMarketingConsent = false + private var isMarketingConsentRequired = false + private var isMarketingConsentAccepted = false + private var isSubmitting = false + private var isSubmitted = false + private var currentSubmitErrorMessage: String? + private var currentForm: ContactForm? + private var personalDataLink: URL? + private var theme = ConsultantTheme.defaultTheme + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + package func prepareForReuse() { + fieldTextFields.values.forEach { $0.text = nil } + isSubmitted = false + currentSubmitErrorMessage = nil + updateErrorView() + setSubmitting(false) + onSubmit = nil + onPersonalDataLinkTap = nil + personalDataLink = nil + } + + private func setup() { + backgroundColor = .clear + setupSubviews() + setupConstraints() + applyTheme() + } + + private func setupSubviews() { + addSubview(contentStackView) + avatarContainerView.addSubview(avatarBackgroundView) + avatarContainerView.addSubview(avatarImageView) + personalDataConsentControl.addSubview(personalDataCheckboxButton) + personalDataConsentControl.addSubview(personalDataConsentTextView) + marketingConsentControl.addSubview(marketingConsentCheckboxButton) + marketingConsentControl.addSubview(marketingConsentLabel) + submitButton.addSubview(submitActivityIndicator) + + contentStackView.addArrangedSubview(avatarContainerView) + contentStackView.addArrangedSubview(messageLabel) + contentStackView.addArrangedSubview(formStackView) + formStackView.addArrangedSubview(fieldsStackView) + formStackView.addArrangedSubview(errorLabel) + formStackView.addArrangedSubview(resultLabel) + formStackView.addArrangedSubview(submitButton) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + contentStackView.topAnchor.constraint(equalTo: topAnchor), + contentStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + + avatarContainerView.heightAnchor.constraint(equalToConstant: Self.avatarSize), + avatarBackgroundView.centerXAnchor.constraint(equalTo: avatarContainerView.centerXAnchor), + avatarBackgroundView.centerYAnchor.constraint(equalTo: avatarContainerView.centerYAnchor), + avatarBackgroundView.widthAnchor.constraint(equalToConstant: Self.avatarBackgroundSize), + avatarBackgroundView.heightAnchor.constraint(equalToConstant: Self.avatarBackgroundSize), + avatarImageView.centerXAnchor.constraint(equalTo: avatarContainerView.centerXAnchor), + avatarImageView.centerYAnchor.constraint(equalTo: avatarContainerView.centerYAnchor), + avatarImageView.widthAnchor.constraint(equalToConstant: Self.avatarSize), + avatarImageView.heightAnchor.constraint(equalToConstant: Self.avatarSize), + + personalDataCheckboxButton.leadingAnchor.constraint(equalTo: personalDataConsentControl.leadingAnchor), + personalDataCheckboxButton.topAnchor.constraint(equalTo: personalDataConsentControl.topAnchor), + personalDataCheckboxButton.widthAnchor.constraint(equalToConstant: Self.checkboxSize), + personalDataCheckboxButton.heightAnchor.constraint(equalToConstant: Self.checkboxSize), + personalDataConsentTextView.leadingAnchor.constraint( + equalTo: personalDataCheckboxButton.trailingAnchor, + constant: Self.checkboxTextSpacing + ), + personalDataConsentTextView.topAnchor.constraint(equalTo: personalDataConsentControl.topAnchor), + personalDataConsentTextView.trailingAnchor.constraint(equalTo: personalDataConsentControl.trailingAnchor), + personalDataConsentTextView.bottomAnchor.constraint(equalTo: personalDataConsentControl.bottomAnchor), + + marketingConsentCheckboxButton.leadingAnchor.constraint(equalTo: marketingConsentControl.leadingAnchor), + marketingConsentCheckboxButton.topAnchor.constraint(equalTo: marketingConsentControl.topAnchor), + marketingConsentCheckboxButton.widthAnchor.constraint(equalToConstant: Self.checkboxSize), + marketingConsentCheckboxButton.heightAnchor.constraint(equalToConstant: Self.checkboxSize), + marketingConsentLabel.leadingAnchor.constraint( + equalTo: marketingConsentCheckboxButton.trailingAnchor, + constant: Self.checkboxTextSpacing + ), + marketingConsentLabel.topAnchor.constraint(equalTo: marketingConsentControl.topAnchor), + marketingConsentLabel.trailingAnchor.constraint(equalTo: marketingConsentControl.trailingAnchor), + marketingConsentLabel.bottomAnchor.constraint(equalTo: marketingConsentControl.bottomAnchor), + + submitButton.heightAnchor.constraint(equalToConstant: Self.buttonHeight), + submitActivityIndicator.centerXAnchor.constraint(equalTo: submitButton.centerXAnchor), + submitActivityIndicator.centerYAnchor.constraint(equalTo: submitButton.centerYAnchor) + ]) + } + + private func applyTheme() { + avatarImageView.tintColor = theme.brandColor + messageLabel.textColor = theme.primaryTextColor + resultLabel.textColor = theme.primaryTextColor + personalDataCheckboxButton.tintColor = theme.brandColor + personalDataConsentTextView.attributedText = makePersonalDataConsentText() + marketingConsentCheckboxButton.tintColor = theme.brandColor + marketingConsentLabel.textColor = theme.primaryTextColor + errorLabel.textColor = Self.errorColor + submitButton.tintColor = theme.mainIconColor + submitButton.layer.cornerRadius = theme.roundedElements + ? MessageLayout.contentCornerRadius + : MessageLayout.compactCornerRadius + submitButton.setTitleColor(theme.mainTextColor, for: .normal) + submitActivityIndicator.color = theme.mainIconColor + + fieldTextFields.values.forEach { textField in + textField.backgroundColor = theme.areaBackgroundColor + textField.layer.cornerRadius = theme.roundedElements + ? MessageLayout.contentCornerRadius + : MessageLayout.compactCornerRadius + textField.layer.borderColor = theme.areaBorderColor.cgColor + textField.textColor = theme.areaTextColor + if let placeholder = textField.placeholder { + textField.attributedPlaceholder = NSAttributedString( + string: placeholder, + attributes: [.foregroundColor: theme.placeholderTextColor] + ) + } + } + } + + package func configure( + form: ContactForm, + layoutMode: LayoutMode + ) { + currentForm = form + isSubmitted = form.isSubmitted + currentSubmitErrorMessage = form.submitErrorMessage?.nilIfEmpty + setSubmitting(false) + + let fields = makeContactFieldDescriptors(form: form) + let needsPersonalDataConsent = form.fields[.personalData] != nil + let marketingConsentField = form.fields[.marketingConsent] + let needsMarketingConsent = marketingConsentField != nil + guard !fields.isEmpty || needsPersonalDataConsent || needsMarketingConsent else { + isHidden = true + return + } + + isHidden = false + fieldTextFields = [:] + requiredFieldKeys = Set(fields.filter(\.isRequired).map(\.key)) + requiresPersonalDataConsent = needsPersonalDataConsent + isPersonalDataConsentAccepted = false + hasMarketingConsent = needsMarketingConsent + isMarketingConsentRequired = marketingConsentField?.required == true + isMarketingConsentAccepted = false + + configureLayoutMode(layoutMode) + + messageLabel.text = Self.messageText(for: form) + messageLabel.isHidden = messageLabel.text?.isEmpty != false + formStackView.isHidden = false + fieldsStackView.isHidden = form.isSubmitted + errorLabel.isHidden = true + resultLabel.text = Self.resultText(for: form) + resultLabel.isHidden = !form.isSubmitted || resultLabel.text?.isEmpty != false + submitButton.isHidden = form.isSubmitted + + fieldsStackView.arrangedSubviews.forEach { view in + fieldsStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + if form.isSubmitted { + fieldTextFields = [:] + requiredFieldKeys = [] + requiresPersonalDataConsent = false + isPersonalDataConsentAccepted = false + hasMarketingConsent = false + isMarketingConsentRequired = false + isMarketingConsentAccepted = false + currentSubmitErrorMessage = nil + updateErrorView() + updateSubmitLoadingState() + updateSubmitButtonState() + return + } + + fields.forEach { descriptor in + fieldsStackView.addArrangedSubview(makeContactTextField(descriptor: descriptor)) + } + + if requiresPersonalDataConsent { + fieldsStackView.addArrangedSubview(personalDataConsentControl) + } + + if hasMarketingConsent { + fieldsStackView.addArrangedSubview(marketingConsentControl) + } + + updatePersonalDataConsentView() + updateMarketingConsentView() + updateErrorView() + updateSubmitLoadingState() + updateSubmitButtonState() + } + + package func setPersonalDataLink(_ link: URL?) { + personalDataLink = link + personalDataConsentTextView.attributedText = makePersonalDataConsentText() + } + + package func setTheme(_ theme: ConsultantTheme) { + self.theme = theme + applyTheme() + updateSubmitButtonState() + } + + package func setSubmitting(_ isSubmitting: Bool) { + self.isSubmitting = isSubmitting + updateSubmitLoadingState() + updateSubmitButtonState() + } + + package func setSubmitError(_ message: String?) { + currentSubmitErrorMessage = message?.nilIfEmpty + updateErrorView() + } + + package func submitButtonFrame(in view: UIView) -> CGRect { + submitButton.convert(submitButton.bounds, to: view) + } + + private func configureLayoutMode(_ layoutMode: LayoutMode) { + avatarContainerView.isHidden = layoutMode == .messageBubble + messageLabel.textAlignment = layoutMode == .fullScreen ? .center : .left + contentStackView.spacing = layoutMode == .fullScreen + ? Self.fullScreenContentSpacing + : Self.messageContentSpacing + formStackView.spacing = layoutMode == .fullScreen + ? Self.fullScreenFormSpacing + : Self.messageFormSpacing + } + + private func makeContactFieldDescriptors(form: ContactForm) -> [ContactFieldDescriptor] { + Self.visibleContactFieldKeys(form: form).compactMap { key in + guard let field = form.fields[key] else { return nil } + return makeContactFieldDescriptor( + key: key, + field: field, + prefill: Self.standardContactFieldKeys.contains(key) + ? makePrefillValue(key: key, form: form) + : nil + ) + } + } + + private func makeContactFieldDescriptor( + key: ContactFormFieldKey, + field: ContactFormField, + prefill: String? + ) -> ContactFieldDescriptor { + switch key { + case .name: + return ContactFieldDescriptor( + key: key, + title: ConsultantLocalization.contactFormFieldName.localized, + prefill: prefill, + keyboardType: .default, + textContentType: .name, + autocapitalizationType: .words, + isRequired: field.required + ) + case .phone: + return ContactFieldDescriptor( + key: key, + title: ConsultantLocalization.contactFormFieldPhone.localized, + prefill: prefill, + keyboardType: .phonePad, + textContentType: .telephoneNumber, + autocapitalizationType: .none, + isRequired: field.required + ) + case .email: + return ContactFieldDescriptor( + key: key, + title: ConsultantLocalization.contactFormFieldEmail.localized, + prefill: prefill, + keyboardType: .emailAddress, + textContentType: .emailAddress, + autocapitalizationType: .none, + isRequired: field.required + ) + case .custom("comment"): + return ContactFieldDescriptor( + key: key, + title: ConsultantLocalization.contactFormFieldComment.localized, + prefill: prefill, + keyboardType: .default, + textContentType: nil, + autocapitalizationType: .sentences, + isRequired: field.required + ) + default: + return ContactFieldDescriptor( + key: key, + title: key.rawValue, + prefill: prefill, + keyboardType: .default, + textContentType: nil, + autocapitalizationType: .sentences, + isRequired: field.required + ) + } + } + + private func makePrefillValue( + key: ContactFormFieldKey, + form: ContactForm + ) -> String? { + if let fieldValue = form.fieldValues[key.rawValue] { + return fieldValue + } + + switch key { + case .name: + return form.prefill.name + case .phone: + return form.prefill.phone + case .email: + return form.prefill.email + default: + return form.fieldValues[key.rawValue] + } + } + + private func makeContactTextField(descriptor: ContactFieldDescriptor) -> UITextField { + let textField = UITextField() + textField.translatesAutoresizingMaskIntoConstraints = false + textField.layer.borderWidth = 0.5 + textField.font = MessageBubbleStyle.semiboldLargeFont + textField.keyboardType = descriptor.keyboardType + textField.textContentType = descriptor.textContentType + textField.autocapitalizationType = descriptor.autocapitalizationType + textField.autocorrectionType = .no + textField.text = descriptor.prefill + textField.addTarget( + self, + action: #selector(contactTextFieldDidChange), + for: .editingChanged + ) + textField.attributedPlaceholder = NSAttributedString( + string: descriptor.title + (descriptor.isRequired ? " *" : ""), + attributes: [ + .foregroundColor: theme.placeholderTextColor + ] + ) + textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1)) + textField.leftViewMode = .always + textField.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1)) + textField.rightViewMode = .always + textField.accessibilityIdentifier = + "contactForm\(descriptor.key.rawValue.capitalized)TextField" + + NSLayoutConstraint.activate([ + textField.heightAnchor.constraint(equalToConstant: Self.fieldHeight) + ]) + + fieldTextFields[descriptor.key] = textField + applyTheme() + return textField + } + + @objc private func contactTextFieldDidChange() { + clearDisplayedSubmitError() + updateSubmitButtonState() + } + + @objc private func togglePersonalDataConsent() { + guard requiresPersonalDataConsent, !isSubmitting, !isSubmitted else { return } + isPersonalDataConsentAccepted.toggle() + clearDisplayedSubmitError() + updatePersonalDataConsentView() + updateSubmitButtonState() + } + + @objc private func toggleMarketingConsent() { + guard hasMarketingConsent, !isSubmitting, !isSubmitted else { return } + isMarketingConsentAccepted.toggle() + clearDisplayedSubmitError() + updateMarketingConsentView() + updateSubmitButtonState() + } + + @objc private func submitContactForm() { + guard submitButton.isEnabled, !isSubmitting, !isSubmitted else { return } + + endEditing(true) + onSubmit?( + ContactFormSubmitData( + name: contactFieldValue(for: .name), + email: contactFieldValue(for: .email), + phone: contactFieldValue(for: .phone), + comment: contactFieldValue(for: .custom("comment")), + marketingConsent: hasMarketingConsent ? isMarketingConsentAccepted : nil, + actionID: currentForm?.actionID, + kind: currentForm?.kind ?? .contact + ) + ) + } + + private func contactFieldValue(for key: ContactFormFieldKey) -> String? { + fieldTextFields[key]?.text?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + private func updatePersonalDataConsentView() { + personalDataConsentControl.isHidden = !requiresPersonalDataConsent + + let image = isPersonalDataConsentAccepted + ? ConsultantImageResource.checkboxOn.image + : ConsultantImageResource.checkboxOff.image + personalDataCheckboxButton.setImage(image, for: .normal) + personalDataCheckboxButton.accessibilityValue = isPersonalDataConsentAccepted + ? ConsultantLocalization.commonOn.localized + : ConsultantLocalization.commonOff.localized + } + + private func updateMarketingConsentView() { + marketingConsentControl.isHidden = !hasMarketingConsent + + let image = isMarketingConsentAccepted + ? ConsultantImageResource.checkboxOn.image + : ConsultantImageResource.checkboxOff.image + marketingConsentCheckboxButton.setImage(image, for: .normal) + marketingConsentCheckboxButton.accessibilityValue = isMarketingConsentAccepted + ? ConsultantLocalization.commonOn.localized + : ConsultantLocalization.commonOff.localized + } + + private func updateErrorView() { + errorLabel.text = currentSubmitErrorMessage + errorLabel.isHidden = currentSubmitErrorMessage == nil + } + + private func clearDisplayedSubmitError() { + guard currentSubmitErrorMessage != nil else { + return + } + + currentSubmitErrorMessage = nil + updateErrorView() + } + + private func updateSubmitButtonState() { + let requiredFieldsAreFilled = requiredFieldKeys.allSatisfy { key in + fieldTextFields[key]?.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + let personalDataIsAccepted = !requiresPersonalDataConsent || isPersonalDataConsentAccepted + let marketingConsentIsAccepted = !isMarketingConsentRequired || isMarketingConsentAccepted + let isEnabled = requiredFieldsAreFilled && + personalDataIsAccepted && + marketingConsentIsAccepted && + !isSubmitting && + !isSubmitted + + submitButton.isEnabled = isEnabled + submitButton.backgroundColor = isEnabled || isSubmitting + ? theme.brandColor + : Self.disabledButtonColor + } + + private func updateSubmitLoadingState() { + let isInputEnabled = !isSubmitting && !isSubmitted + fieldTextFields.values.forEach { $0.isEnabled = isInputEnabled } + personalDataConsentControl.isEnabled = isInputEnabled + personalDataCheckboxButton.isEnabled = isInputEnabled + marketingConsentControl.isEnabled = isInputEnabled + marketingConsentCheckboxButton.isEnabled = isInputEnabled + + if isSubmitting { + submitButton.setTitle(nil, for: .normal) + submitActivityIndicator.startAnimating() + } else if isSubmitted { + submitActivityIndicator.stopAnimating() + submitButton.setTitle(ConsultantLocalization.contactFormSubmitted.localized, for: .normal) + } else { + submitActivityIndicator.stopAnimating() + submitButton.setTitle(ConsultantLocalization.contactFormSubmit.localized, for: .normal) + } + } + + private func makePersonalDataConsentText() -> NSAttributedString { + let text = ConsultantLocalization.contactFormPersonalDataConsent.localized + let linkText = ConsultantLocalization.contactFormPersonalDataLinkText.localized + let attributedText = NSMutableAttributedString( + string: text, + attributes: [ + .foregroundColor: theme.primaryTextColor, + .font: MessageBubbleStyle.regularSmallFont + ] + ) + let linkRange = (text as NSString).range(of: linkText) + if linkRange.location != NSNotFound { + var linkAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.systemBlue + ] + if let personalDataLink { + linkAttributes[.link] = personalDataLink + } + attributedText.addAttributes(linkAttributes, range: linkRange) + } + return attributedText + } + +} + +extension ContactFormContentView: UITextViewDelegate { + + package func textView( + _ textView: UITextView, + shouldInteractWith URL: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + onPersonalDataLinkTap?(URL) + return false + } +} + +package extension ContactFormContentView { + + enum LayoutMode { + case fullScreen + case messageBubble + } + + private struct ContactFieldDescriptor { + let key: ContactFormFieldKey + let title: String + let prefill: String? + let keyboardType: UIKeyboardType + let textContentType: UITextContentType? + let autocapitalizationType: UITextAutocapitalizationType + let isRequired: Bool + } +} + +package extension ContactFormContentView { + + static let formWidth: CGFloat = 326 + + static func preferredHeight( + for form: ContactForm, + width: CGFloat, + layoutMode: LayoutMode + ) -> CGFloat { + if form.isSubmitted { + let messageHeight = textHeight( + messageText(for: form), + width: width, + font: MessageBubbleStyle.messageFont + ) + let resultHeight = textHeight( + resultText(for: form), + width: width, + font: MessageBubbleStyle.messageFont + ) + let showsAvatar = layoutMode == .fullScreen + let contentSpacing = layoutMode == .fullScreen + ? Self.fullScreenContentSpacing + : Self.messageContentSpacing + let visibleBlocks = (showsAvatar ? 1 : 0) + + (messageHeight > 0 ? 1 : 0) + + (resultHeight > 0 ? 1 : 0) + let contentGapHeight = CGFloat(max(0, visibleBlocks - 1)) * contentSpacing + + return ceil( + (showsAvatar ? Self.avatarSize : 0) + + messageHeight + + resultHeight + + contentGapHeight + ) + } + + let fields = visibleContactFieldKeys(form: form).count + let hasPersonalDataConsent = form.fields[.personalData] != nil + let hasMarketingConsent = form.fields[.marketingConsent] != nil + let fieldBlocks = fields + + (hasPersonalDataConsent ? 1 : 0) + + (hasMarketingConsent ? 1 : 0) + let fieldsHeight = CGFloat(fields) * Self.fieldHeight + + checkboxHeight( + text: ConsultantLocalization.contactFormPersonalDataConsent.localized, + width: width + ) * CGFloat(hasPersonalDataConsent ? 1 : 0) + + checkboxHeight( + text: ConsultantLocalization.contactFormMarketingConsent.localized, + width: width + ) * CGFloat(hasMarketingConsent ? 1 : 0) + + CGFloat(max(0, fieldBlocks - 1)) * Self.fieldsSpacing + let formSpacing = layoutMode == .fullScreen + ? Self.fullScreenFormSpacing + : Self.messageFormSpacing + let errorHeight = textHeight( + form.submitErrorMessage?.nilIfEmpty, + width: width, + font: MessageBubbleStyle.regularSmallFont + ) + let formHeight = fieldsHeight + + formSpacing + + (errorHeight > 0 ? errorHeight + formSpacing : 0) + + Self.buttonHeight + let messageHeight = textHeight( + messageText(for: form), + width: width, + font: MessageBubbleStyle.messageFont + ) + let showsAvatar = layoutMode == .fullScreen + let contentSpacing = layoutMode == .fullScreen + ? Self.fullScreenContentSpacing + : Self.messageContentSpacing + let visibleBlocks = (showsAvatar ? 1 : 0) + (messageHeight > 0 ? 1 : 0) + 1 + let contentGapHeight = CGFloat(max(0, visibleBlocks - 1)) * contentSpacing + + return ceil( + (showsAvatar ? Self.avatarSize : 0) + + messageHeight + + formHeight + + contentGapHeight + ) + } +} + +private extension ContactFormContentView { + + static func messageText(for form: ContactForm) -> String { + return form.text?.nilIfEmpty + ?? (form.kind == .offline + ? ConsultantLocalization.offlineDefaultText.localized + : ConsultantLocalization.contactFormDefaultText.localized) + } + + static func resultText(for form: ContactForm) -> String { + form.resultText?.nilIfEmpty + ?? (form.kind == .offline + ? ConsultantLocalization.offlineFormDefaultResultText.localized + : ConsultantLocalization.contactFormDefaultText.localized) + } + + static func visibleContactFieldKeys(form: ContactForm) -> [ContactFormFieldKey] { + let customFields = form.fields.keys.filter { + !standardContactFieldKeys.contains($0) + && $0 != .personalData + && $0 != .marketingConsent + } + + return standardContactFieldKeys.filter { form.fields[$0] != nil } + customFields.sorted() + } + + static func checkboxHeight(text: String, width: CGFloat) -> CGFloat { + let labelWidth = max(1, width - Self.checkboxSize - Self.checkboxTextSpacing) + let labelHeight = textHeight( + text, + width: labelWidth, + font: MessageBubbleStyle.regularSmallFont + ) + + return max(Self.checkboxSize, labelHeight) + } + + static func textHeight( + _ text: String?, + width: CGFloat, + font: UIFont + ) -> CGFloat { + guard let text, !text.isEmpty else { + return 0 + } + + return ceil( + (text as NSString).boundingRect( + with: CGSize(width: max(1, width), height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], + context: nil + ).height + ) + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormView.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormView.swift new file mode 100644 index 0000000..d9150bc --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/ContactForm/ContactFormView.swift @@ -0,0 +1,244 @@ +import UIKit + +/// Displays a fullscreen contact form and keeps its submit button visible above the keyboard. + package final class ContactFormView: UIView { + + private static let centerYOffset: CGFloat = 32 + package static let horizontalInset: CGFloat = 24 + package static let topInset: CGFloat = 32 + private static let defaultBottomSpacing: CGFloat = 24 + private static let keyboardButtonSpacing: CGFloat = 20 + + private lazy var contentView: ContactFormContentView = { + let view = ContactFormContentView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var isObservingKeyboard = false + private var theme = ConsultantTheme.defaultTheme + + /// Called when the user submits valid contact data. + package var onSubmit: ((ContactFormSubmitData) -> Void)? { + didSet { + contentView.onSubmit = onSubmit + } + } + + package var onPersonalDataLinkTap: ((URL) -> Void)? { + didSet { + contentView.onPersonalDataLinkTap = onPersonalDataLinkTap + } + } + + private lazy var dismissKeyboardTapGesture: UITapGestureRecognizer = { + let gesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + gesture.cancelsTouchesInView = false + gesture.delegate = self + return gesture + }() + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + deinit { + unregisterForKeyboardNotifications() + } + + package override func didMoveToWindow() { + super.didMoveToWindow() + + if window == nil { + unregisterForKeyboardNotifications() + } else { + registerForKeyboardNotifications() + } + } + + private func setup() { + backgroundColor = theme.screenBackgroundColor + setupGesture() + setupSubviews() + setupConstraints() + } + + private func setupGesture() { + addGestureRecognizer(dismissKeyboardTapGesture) + } + + private func setupSubviews() { + addSubview(contentView) + } + + private func setupConstraints() { + let formWidthConstraint = contentView.widthAnchor.constraint( + equalToConstant: ContactFormContentView.formWidth + ) + formWidthConstraint.priority = .defaultHigh + + let centerYConstraint = contentView.centerYAnchor.constraint( + equalTo: centerYAnchor, + constant: -Self.centerYOffset + ) + centerYConstraint.priority = .defaultHigh + + NSLayoutConstraint.activate([ + contentView.centerXAnchor.constraint(equalTo: centerXAnchor), + centerYConstraint, + contentView.leadingAnchor.constraint( + greaterThanOrEqualTo: leadingAnchor, + constant: Self.horizontalInset + ), + contentView.trailingAnchor.constraint( + lessThanOrEqualTo: trailingAnchor, + constant: -Self.horizontalInset + ), + contentView.topAnchor.constraint( + greaterThanOrEqualTo: safeAreaLayoutGuide.topAnchor, + constant: Self.topInset + ), + contentView.bottomAnchor.constraint( + lessThanOrEqualTo: bottomAnchor, + constant: -Self.defaultBottomSpacing + ), + formWidthConstraint + ]) + } + + /// Configures the fullscreen contact form content. + package func configure(form: ContactForm) { + contentView.configure( + form: form, + layoutMode: .fullScreen + ) + } + + package func setPersonalDataLink(_ link: URL?) { + contentView.setPersonalDataLink(link) + } + + /// Updates the form submitting state and disables duplicate submissions while active. + package func setSubmitting(_ isSubmitting: Bool) { + contentView.setSubmitting(isSubmitting) + } + + /// Displays or clears submit error text inside the form. + package func setSubmitError(_ message: String?) { + contentView.setSubmitError(message) + } + + /// Applies consultant theme colors to the fullscreen form. + package func setTheme(_ theme: ConsultantTheme) { + self.theme = theme + backgroundColor = theme.screenBackgroundColor + contentView.setTheme(theme) + } + + private func registerForKeyboardNotifications() { + guard !isObservingKeyboard else { return } + isObservingKeyboard = true + + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + private func unregisterForKeyboardNotifications() { + guard isObservingKeyboard else { return } + isObservingKeyboard = false + + NotificationCenter.default.removeObserver( + self, + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.removeObserver( + self, + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc private func dismissKeyboard() { + endEditing(true) + } + + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard + let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { + return + } + + let keyboardFrame = convert(endFrame, from: nil) + let keyboardOverlap = max(0, bounds.maxY - keyboardFrame.minY) + let verticalOffset = keyboardOverlap > 0 + ? makeKeyboardVerticalOffset(keyboardFrame: keyboardFrame) + : 0 + + updateContentVerticalOffset(verticalOffset, notification: notification) + } + + @objc private func keyboardWillHide(_ notification: Notification) { + updateContentVerticalOffset(0, notification: notification) + } + + private func makeKeyboardVerticalOffset(keyboardFrame: CGRect) -> CGFloat { + layoutIfNeeded() + + let currentOffset = max(0, -contentView.transform.ty) + let submitButtonFrame = contentView.submitButtonFrame(in: self) + let submitButtonMaxYWithoutTransform = submitButtonFrame.maxY + currentOffset + + return max(0, submitButtonMaxYWithoutTransform + Self.keyboardButtonSpacing - keyboardFrame.minY) + } + + private func updateContentVerticalOffset(_ offset: CGFloat, notification: Notification) { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 + let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0 + let options = UIView.AnimationOptions(rawValue: UInt(curve << 16)) + + UIView.animate( + withDuration: duration, + delay: 0, + options: options, + animations: { + self.contentView.transform = CGAffineTransform(translationX: 0, y: -offset) + } + ) + } +} + +extension ContactFormView: UIGestureRecognizerDelegate { + + package func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + var view = touch.view + while let currentView = view { + if currentView is UIControl || currentView is UITextField { + return false + } + view = currentView.superview + } + + return true + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/FooterBrandingView.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/FooterBrandingView.swift new file mode 100644 index 0000000..b245d6f --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/FooterBrandingView.swift @@ -0,0 +1,224 @@ +import Core +import UIKit + +/// Branding footer shown when backend `domain_config.footer` enables it. +package final class FooterBrandingView: UIView { + + /// Called when the visitor taps the branding footer. + package var onTap: ((URL) -> Void)? + + private lazy var contentStackView: UIStackView = { + let view = UIStackView() + view.axis = .horizontal + view.alignment = .center + view.distribution = .fill + view.spacing = 4 + view.isUserInteractionEnabled = false + view.accessibilityIdentifier = "consultantFooterBrandingContentStackView" + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var madeInLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.regularCompactFont + label.text = ConsultantLocalization.footerMadeIn.localized + label.textColor = ConsultantTheme.defaultTheme.messageDateColor + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var brandLabel: UILabel = { + let label = UILabel() + label.font = MessageBubbleStyle.semiboldCompactFont + label.textColor = ConsultantTheme.defaultTheme.messageDateColor + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var logoImageView: UIImageView = { + let view = UIImageView() + view.accessibilityIdentifier = "consultantFooterBrandingLogoImageView" + view.contentMode = .scaleAspectFit + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var tapGestureRecognizer: UITapGestureRecognizer = { + UITapGestureRecognizer(target: self, action: #selector(handleTap)) + }() + + private var logoLoadTask: ImageLoadTask? + private var currentURL: URL? + private var currentLogoURL: URL? + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + deinit { + logoLoadTask?.cancel() + } + + /// Applies backend domain branding config and current consultant theme. + package func configure( + domainConfig: DomainConfig?, + theme: ConsultantTheme + ) { + guard domainConfig?.footer == true else { + resetLogoLoading() + isHidden = true + currentURL = nil + currentLogoURL = nil + return + } + + isHidden = false + backgroundColor = theme.bottomBarColor + madeInLabel.textColor = theme.messageDateColor + brandLabel.textColor = theme.messageDateColor + currentURL = domainConfig?.url + brandLabel.text = Self.brandName(from: domainConfig?.url) + + guard let logoURL = FooterBrandingLogoLoader.logoURL( + domainConfig: domainConfig, + theme: theme + ) else { + resetLogoLoading() + currentLogoURL = nil + return + } + + guard currentLogoURL != logoURL else { + return + } + + resetLogoLoading() + currentLogoURL = logoURL + logoLoadTask = ImageLoader.shared.loadImage(from: logoURL) { [weak self] image in + guard self?.currentLogoURL == logoURL else { + return + } + + self?.logoImageView.image = image + self?.logoImageView.isHidden = image == nil + self?.brandLabel.isHidden = image != nil + } + } + + private func setup() { + setupSubviews() + setupConstraints() + } + + private func setupSubviews() { + accessibilityIdentifier = "consultantFooterBrandingView" + clipsToBounds = true + addGestureRecognizer(tapGestureRecognizer) + addSubview(contentStackView) + contentStackView.addArrangedSubview(madeInLabel) + contentStackView.addArrangedSubview(brandLabel) + contentStackView.addArrangedSubview(logoImageView) + } + + private func setupConstraints() { + let contentTopConstraint = contentStackView.topAnchor.constraint(equalTo: topAnchor, constant: 4) + let contentBottomConstraint = contentStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10) + contentTopConstraint.priority = .defaultHigh + contentBottomConstraint.priority = .defaultHigh + + NSLayoutConstraint.activate([ + contentTopConstraint, + contentStackView.centerXAnchor.constraint(equalTo: centerXAnchor), + contentBottomConstraint, + contentStackView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16), + contentStackView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), + + logoImageView.heightAnchor.constraint(equalToConstant: 17), + logoImageView.widthAnchor.constraint(lessThanOrEqualToConstant: 96) + ]) + } + + private func resetLogoLoading() { + logoLoadTask?.cancel() + logoLoadTask = nil + logoImageView.image = nil + logoImageView.isHidden = true + brandLabel.isHidden = false + } + + @objc private func handleTap() { + guard let currentURL else { + return + } + + onTap?(currentURL) + } +} + +private extension FooterBrandingView { + + static func brandName(from url: URL?) -> String { + guard let host = url?.host?.lowercased() else { + return "retailCRM" + } + + return host.contains("simla") ? "Simla" : "retailCRM" + } +} + +/// Resolves and warms the same logo cache that the footer reads when it appears. +package enum FooterBrandingLogoLoader { + + package static func logoURL( + domainConfig: DomainConfig?, + theme: ConsultantTheme + ) -> URL? { + guard domainConfig?.footer == true, let url = domainConfig?.logo else { + return nil + } + + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + // These published branding assets have PNG equivalents in both themes. + let knownPaths = ["retailcrm", "simla"].map { + "/eu-central-1/retailcrm-static/branding/\($0)/logo/" + } + let directory = url.deletingLastPathComponent().path + "/" + let name = url.deletingPathExtension().lastPathComponent + let isStandardLogo = components.host?.lowercased() == "s3-s1.retailcrm.tech" + && knownPaths.contains(directory) + && ["logo_horiz", "logo_horiz_white"].contains(name) + && ["svg", "png"].contains(url.pathExtension.lowercased()) + + if isStandardLogo { + let resolvedName = theme.isDarkTheme ? "logo_horiz_white" : name + components.path = directory + resolvedName + ".png" + return components.url + } + + // UIImage cannot decode arbitrary remote SVGs; keep the brand name as fallback. + return url.pathExtension.lowercased() == "svg" ? nil : url + } + + @discardableResult + package static func prefetch( + domainConfig: DomainConfig?, + theme: ConsultantTheme + ) -> ImageLoadTask? { + guard let url = logoURL(domainConfig: domainConfig, theme: theme) else { + return nil + } + + return ImageLoader.shared.loadImage(from: url) { _ in } + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputBar.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputBar.swift new file mode 100644 index 0000000..9633792 --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputBar.swift @@ -0,0 +1,427 @@ +import UIKit + +/// Displays the chat composer with text input, attachment previews, and send controls. +package final class InputBar: UIView { + + private static let horizontalSpacing: CGFloat = 10 + private static let topSpacing: CGFloat = 8 + private static let bottomSpacing: CGFloat = 8 + private static let minTextViewHeight: CGFloat = 36 + private static let maxTextViewHeight: CGFloat = 180 + private static let buttonSize: CGFloat = 36 + private static let attachmentButtonSize: CGFloat = 28 + private static let attachmentButtonBottomSpacing: CGFloat = 12 + private static let sendButtonLeadingSpacing: CGFloat = 7 + private static let sendButtonTrailingSpacing: CGFloat = 12 + private static let roundedCornerRadius: CGFloat = 10 + private static let rectangularCornerRadius: CGFloat = 4 + private static let separatorHeight: CGFloat = 1 + private static let attachmentCellSize = CGSize(width: 86, height: 86) + private static let attachmentsHeight: CGFloat = 102 + private static let attachmentItemSpacing: CGFloat = 8 + private static let attachmentSectionInset = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10) + private static let attachmentVisibilityAnimationDuration: TimeInterval = 0.2 + private static let sendButtonColorAnimationDuration: TimeInterval = 0.2 + + private lazy var topSeparatorView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var attachmentsSeparatorView: UIView = { + let view = UIView() + view.alpha = 0 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var attachmentButton: UIButton = { + let button = UIButton(type: .system) + button.setImage(ConsultantImageResource.attachmentIcon.templateImage, for: .normal) + button.addTarget(self, action: #selector(attachmentButtonTapped), for: .touchUpInside) + button.accessibilityIdentifier = "attachmentMenuButton" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var textView: InputTextView = { + let view = InputTextView() + view.layer.borderWidth = 0.5 + view.font = MessageBubbleStyle.messageFont + view.placeholder = ConsultantLocalization.messageInputPlaceholder.localized + view.accessibilityIdentifier = "messageInputTextView" + view.returnKeyType = .default + view.delegate = self + view.isScrollEnabled = false + view.textContainerInset = UIEdgeInsets(top: 7, left: 12, bottom: 7, right: 12) + view.textContainer.lineFragmentPadding = 0 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var sendButton: UIButton = { + let button = UIButton(type: .custom) + button.adjustsImageWhenDisabled = false + button.setImage(ConsultantImageResource.sendIcon.templateImage, for: .normal) + button.addTarget(self, action: #selector(sendButtonTapped), for: .touchUpInside) + button.accessibilityIdentifier = "sendMessageButton" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var attachmentsCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = Self.attachmentCellSize + layout.sectionInset = Self.attachmentSectionInset + layout.minimumLineSpacing = Self.attachmentItemSpacing + layout.minimumInteritemSpacing = Self.attachmentItemSpacing + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.alpha = 0 + collectionView.isHidden = true + collectionView.alwaysBounceHorizontal = true + collectionView.showsHorizontalScrollIndicator = false + collectionView.dataSource = self + collectionView.delegate = self + collectionView.accessibilityIdentifier = "inputAttachmentsCollectionView" + collectionView.register( + InputAttachmentCollectionViewCell.self, + forCellWithReuseIdentifier: CellReuseIdentifier.inputAttachment.rawValue + ) + collectionView.translatesAutoresizingMaskIntoConstraints = false + return collectionView + }() + + private var textViewHeightConstraint: NSLayoutConstraint? + private var textViewTopConstraint: NSLayoutConstraint? + private var attachmentsCollectionHeightConstraint: NSLayoutConstraint? + private var inputAttachments: [InputAttachment] = [] + private var cachedIntrinsicContentSize = CGSize(width: UIView.noIntrinsicMetric, height: 0) + private var canSendMessages = false + private var lastMeasuredTextViewWidth: CGFloat = 0 + private var theme = ConsultantTheme.defaultTheme + + /// Called when the user taps send with valid text or sendable attachments. + package var onSend: ((String) -> Void)? + + /// Called when the user taps the attachment button. + package var onOpenAttachmentMenu: ((UIView) -> Void)? + + /// Called when the user removes an attachment from the input preview strip. + package var onRemoveInputAttachment: ((UUID) -> Void)? + + /// Called when the user retries a failed attachment upload. + package var onRetryInputAttachment: ((UUID) -> Void)? + + /// Called when text or attachments change the input bar intrinsic height. + package var onIntrinsicContentSizeChange: (() -> Void)? + + /// Current text entered in the composer. + package var messageText: String { + textView.text ?? "" + } + + private var canSendInputAttachments: Bool { + inputAttachments.allSatisfy(\.canSend) + } + + /// Current height requested by the input bar for its text and attachment content. + package override var intrinsicContentSize: CGSize { + cachedIntrinsicContentSize + } + + /// Recalculates text height when layout gives the text view a new width. + package override func layoutSubviews() { + super.layoutSubviews() + + guard abs(textView.bounds.width - lastMeasuredTextViewWidth) > 0.5 else { + return + } + + updateTextViewHeight() + } + + package override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + setupSubviews() + setupConstraints() + updateTextViewHeight() + applyTheme() + updateSendControls() + } + + private func setupSubviews() { + addSubview(topSeparatorView) + addSubview(attachmentsCollectionView) + addSubview(attachmentsSeparatorView) + addSubview(attachmentButton) + addSubview(textView) + addSubview(sendButton) + } + + private func setupConstraints() { + let textViewHeightConstraint = textView.heightAnchor.constraint( + equalToConstant: Self.minTextViewHeight + ) + let textViewTopConstraint = textView.topAnchor.constraint(equalTo: topAnchor, constant: Self.topSpacing) + let attachmentsCollectionHeightConstraint = attachmentsCollectionView.heightAnchor.constraint( + equalToConstant: 0 + ) + self.textViewHeightConstraint = textViewHeightConstraint + self.textViewTopConstraint = textViewTopConstraint + self.attachmentsCollectionHeightConstraint = attachmentsCollectionHeightConstraint + + NSLayoutConstraint.activate([ + topSeparatorView.topAnchor.constraint(equalTo: topAnchor), + topSeparatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + topSeparatorView.trailingAnchor.constraint(equalTo: trailingAnchor), + topSeparatorView.heightAnchor.constraint(equalToConstant: Self.separatorHeight), + + attachmentsCollectionView.topAnchor.constraint(equalTo: topSeparatorView.bottomAnchor), + attachmentsCollectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + attachmentsCollectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + attachmentsCollectionHeightConstraint, + + attachmentsSeparatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + attachmentsSeparatorView.trailingAnchor.constraint(equalTo: trailingAnchor), + attachmentsSeparatorView.bottomAnchor.constraint(equalTo: attachmentsCollectionView.bottomAnchor), + attachmentsSeparatorView.heightAnchor.constraint(equalToConstant: Self.separatorHeight), + + attachmentButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.horizontalSpacing), + attachmentButton.bottomAnchor.constraint( + equalTo: bottomAnchor, + constant: -Self.attachmentButtonBottomSpacing + ), + attachmentButton.widthAnchor.constraint(equalToConstant: Self.attachmentButtonSize), + attachmentButton.heightAnchor.constraint(equalToConstant: Self.attachmentButtonSize), + + textView.leadingAnchor.constraint( + equalTo: attachmentButton.trailingAnchor, + constant: Self.horizontalSpacing + ), + textViewTopConstraint, + textView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.bottomSpacing), + textViewHeightConstraint, + + sendButton.leadingAnchor.constraint( + equalTo: textView.trailingAnchor, + constant: Self.sendButtonLeadingSpacing + ), + sendButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.sendButtonTrailingSpacing), + sendButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.bottomSpacing), + sendButton.widthAnchor.constraint(equalToConstant: Self.buttonSize), + sendButton.heightAnchor.constraint(equalToConstant: Self.buttonSize) + ]) + } + + private func updateTextViewHeight() { + lastMeasuredTextViewWidth = textView.bounds.width + let fittingSize = CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude) + let requiredHeight = textView.sizeThatFits(fittingSize).height.rounded(.up) + let textViewHeight = min( + Self.maxTextViewHeight, + max(Self.minTextViewHeight, requiredHeight) + ) + let shouldScrollText = requiredHeight > Self.maxTextViewHeight + let attachmentsHeight = inputAttachments.isEmpty ? 0 : Self.attachmentsHeight + let intrinsicHeight = Self.topSpacing + attachmentsHeight + textViewHeight + Self.bottomSpacing + let oldIntrinsicContentSize = cachedIntrinsicContentSize + + textViewHeightConstraint?.constant = textViewHeight + textView.isScrollEnabled = shouldScrollText + cachedIntrinsicContentSize = CGSize(width: UIView.noIntrinsicMetric, height: intrinsicHeight) + + guard abs(oldIntrinsicContentSize.height - cachedIntrinsicContentSize.height) > 0.5 else { + return + } + + invalidateIntrinsicContentSize() + onIntrinsicContentSizeChange?() + } + + private func applyTheme() { + backgroundColor = theme.bottomBarColor + topSeparatorView.backgroundColor = theme.dividerColor + attachmentsSeparatorView.backgroundColor = theme.dividerColor + attachmentButton.tintColor = theme.areaIconColor + textView.backgroundColor = theme.areaBackgroundColor + textView.layer.cornerRadius = theme.roundedElements + ? Self.roundedCornerRadius + : Self.rectangularCornerRadius + textView.layer.borderColor = theme.areaBorderColor.cgColor + textView.textColor = theme.areaTextColor + textView.placeholderColor = theme.placeholderTextColor + sendButton.layer.cornerRadius = theme.roundedElements + ? Self.roundedCornerRadius + : Self.rectangularCornerRadius + } + + private func updateSendControls() { + let text = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + let hasSendableContent = !text.isEmpty || !inputAttachments.isEmpty + let isSendButtonEnabled = canSendMessages && canSendInputAttachments && hasSendableContent + + textView.isEditable = canSendMessages + textView.isSelectable = canSendMessages + sendButton.isEnabled = isSendButtonEnabled + sendButton.isUserInteractionEnabled = isSendButtonEnabled + sendButton.tintColor = theme.mainIconColor + + let backgroundColor = isSendButtonEnabled + ? theme.brandColor + : UIColor(hex: 0x636F7F, alpha: 0.2) + guard sendButton.backgroundColor != backgroundColor else { + return + } + + guard window != nil, !UIAccessibility.isReduceMotionEnabled else { + sendButton.backgroundColor = backgroundColor + return + } + + UIView.animate( + withDuration: Self.sendButtonColorAnimationDuration, + delay: 0, + options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut] + ) { + self.sendButton.backgroundColor = backgroundColor + } + } + + /// Enables or disables message composition controls. + package func setCanSendMessages(_ canSend: Bool) { + guard canSendMessages != canSend else { + return + } + + canSendMessages = canSend + updateSendControls() + } + + /// Clears the entered message text and refreshes composer layout. + package func clearText() { + textView.text = "" + textViewDidChange(textView) + } + + /// Replaces input attachment previews and updates composer height and send availability. + package func setInputAttachments(_ attachments: [InputAttachment]) { + guard inputAttachments != attachments else { + return + } + + let oldAttachmentCount = inputAttachments.count + inputAttachments = attachments + attachmentsCollectionView.reloadData() + updateAttachmentsVisibility(animated: oldAttachmentCount != attachments.count) + updateTextViewHeight() + updateSendControls() + + guard attachments.count > oldAttachmentCount, !attachments.isEmpty else { + return + } + + let indexPath = IndexPath(item: attachments.count - 1, section: 0) + attachmentsCollectionView.scrollToItem(at: indexPath, at: .right, animated: true) + } + + /// Applies consultant colors and shape settings to input controls. + package func setTheme(_ theme: ConsultantTheme) { + self.theme = theme + applyTheme() + updateSendControls() + } + + @objc private func sendButtonTapped() { + let text = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + guard canSendMessages, canSendInputAttachments, !text.isEmpty || !inputAttachments.isEmpty else { + return + } + + onSend?(text) + } + + @objc private func attachmentButtonTapped() { + onOpenAttachmentMenu?(attachmentButton) + } + + private func updateAttachmentsVisibility(animated: Bool) { + let isVisible = !inputAttachments.isEmpty + let updates = { + self.attachmentsCollectionView.alpha = isVisible ? 1 : 0 + self.attachmentsSeparatorView.alpha = isVisible ? 1 : 0 + } + + attachmentsCollectionView.isHidden = false + attachmentsCollectionHeightConstraint?.constant = isVisible ? Self.attachmentsHeight : 0 + textViewTopConstraint?.constant = Self.topSpacing + (isVisible ? Self.attachmentsHeight : 0) + + if animated { + UIView.animate( + withDuration: Self.attachmentVisibilityAnimationDuration, + animations: updates + ) { [weak self] _ in + self?.attachmentsCollectionView.isHidden = !isVisible + } + } else { + updates() + attachmentsCollectionView.isHidden = !isVisible + } + } +} + +extension InputBar: UITextViewDelegate { + + /// Updates placeholder visibility, height, and send availability after text changes. + package func textViewDidChange(_ textView: UITextView) { + self.textView.updatePlaceholderVisibility() + updateTextViewHeight() + updateSendControls() + } +} + +extension InputBar: UICollectionViewDataSource, UICollectionViewDelegate { + + /// Returns the number of attachment previews currently shown above the text input. + package func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + inputAttachments.count + } + + /// Configures an attachment preview cell and forwards remove or retry actions. + package func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard + inputAttachments.indices.contains(indexPath.item), + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: CellReuseIdentifier.inputAttachment.rawValue, + for: indexPath + ) as? InputAttachmentCollectionViewCell + else { + return UICollectionViewCell() + } + + let attachment = inputAttachments[indexPath.item] + cell.configure(attachment) + cell.onRemove = { [weak self, attachmentID = attachment.id] in + self?.onRemoveInputAttachment?(attachmentID) + } + cell.onRetry = { [weak self, attachmentID = attachment.id] in + self?.onRetryInputAttachment?(attachmentID) + } + return cell + } +} diff --git a/Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputTextView.swift b/Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputTextView.swift new file mode 100644 index 0000000..a73d865 --- /dev/null +++ b/Sources/OnlineConsultant/UI/ViewControllers/Views/InputBar/InputTextView.swift @@ -0,0 +1,87 @@ +// +// InputTextView.swift +// OnlineConsultant +// +// Created by Yriy Deviataev on 15.07.2026. +// + +import UIKit + +/// Text view used by the chat composer with built-in placeholder rendering. +package final class InputTextView: UITextView { + + private let placeholderLabel = UILabel() + + /// Placeholder text shown while the input is empty. + package var placeholder: String? { + didSet { + placeholderLabel.text = placeholder + } + } + + /// Placeholder text color applied from the active consultant theme. + package var placeholderColor: UIColor? { + didSet { + placeholderLabel.textColor = placeholderColor + } + } + + /// Updates placeholder visibility when text is changed programmatically. + package override var text: String! { + didSet { + updatePlaceholderVisibility() + } + } + + /// Updates placeholder visibility when attributed text is changed programmatically. + package override var attributedText: NSAttributedString! { + didSet { + updatePlaceholderVisibility() + } + } + + /// Keeps the placeholder label visually aligned with the typed text. + package override var font: UIFont? { + didSet { + placeholderLabel.font = font + } + } + + package override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + setup() + } + + package required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + /// Positions the placeholder inside the editable text container. + package override func layoutSubviews() { + super.layoutSubviews() + + let textContainerInset = self.textContainerInset + placeholderLabel.frame = CGRect( + x: textContainerInset.left, + y: textContainerInset.top, + width: bounds.width - textContainerInset.left - textContainerInset.right, + height: placeholderLabel.intrinsicContentSize.height + ) + } + + private func setup() { + placeholderLabel.numberOfLines = 1 + placeholderLabel.textColor = .placeholderText + placeholderLabel.font = font + placeholderLabel.isUserInteractionEnabled = false + + addSubview(placeholderLabel) + updatePlaceholderVisibility() + } + + /// Shows the placeholder only when the text view has no text. + package func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !(text ?? "").isEmpty + } +} diff --git a/Sources/PushNotificationExtension/CrmSdkNetworkObserver.swift b/Sources/PushNotificationExtension/CrmSdkNetworkObserver.swift new file mode 100644 index 0000000..a53f5af --- /dev/null +++ b/Sources/PushNotificationExtension/CrmSdkNetworkObserver.swift @@ -0,0 +1,8 @@ +import SDKConfiguration + +/// Receives SDK HTTP request, completion, and optional metrics events. +public typealias CrmSdkNetworkObserver = SDKConfiguration.CrmSdkNetworkObserver +/// A prepared HTTP request and its identifier and start time. +public typealias CrmSdkNetworkRequest = SDKConfiguration.CrmSdkNetworkRequest +/// The HTTP transport result, correlated with its request identifier. +public typealias CrmSdkNetworkResponse = SDKConfiguration.CrmSdkNetworkResponse diff --git a/Sources/PushNotificationExtension/Models/PushMessage.swift b/Sources/PushNotificationExtension/Models/PushMessage.swift new file mode 100644 index 0000000..d6cc953 --- /dev/null +++ b/Sources/PushNotificationExtension/Models/PushMessage.swift @@ -0,0 +1,52 @@ +import Foundation + +package struct PushMessage { + package let pushId: String + package let title: String? + package let body: String? + package let imageURL: URL? + package let clickAction: ClickAction? + package let rgCategory: String? + package let buttons: [PushButton] + private let userInfo: [AnyHashable: Any] + + package init( + pushId: String, + title: String?, + body: String?, + imageURL: URL?, + clickAction: ClickAction?, + rgCategory: String?, + buttons: [PushButton], + userInfo: [AnyHashable: Any] + ) { + self.pushId = pushId + self.title = title + self.body = body + self.imageURL = imageURL + self.clickAction = clickAction + self.rgCategory = rgCategory + self.buttons = buttons + self.userInfo = userInfo + } +} + +package struct ClickAction: Equatable { + package let stringUrl: String + package let customAttributes: [String: String]? + + package init(stringUrl: String, customAttributes: [String: String]?) { + self.stringUrl = stringUrl + self.customAttributes = customAttributes + } +} + +package struct PushButton: Equatable { + package let title: String + package let value: String + + package init(title: String, value: String) { + self.title = title + self.value = value + } +} diff --git a/Sources/PushNotificationExtension/Models/PushNotificationStatus.swift b/Sources/PushNotificationExtension/Models/PushNotificationStatus.swift new file mode 100644 index 0000000..4eea4b5 --- /dev/null +++ b/Sources/PushNotificationExtension/Models/PushNotificationStatus.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Push status codes supported by the backend push status API. +public enum PushNotificationStatus: String, Codable, CaseIterable, Sendable { + /// Notification was accepted for sending by the push pipeline. + case sent + + /// Notification was delivered to the device or displayed by the app. + case delivered + + /// User dismissed the notification from Notification Center. + case swiped + + /// User opened the notification. + case clicked + + /// Notification could not be delivered. + case undelivered +} diff --git a/Sources/PushNotificationExtension/Models/PushStatusReportingError.swift b/Sources/PushNotificationExtension/Models/PushStatusReportingError.swift new file mode 100644 index 0000000..0f26f41 --- /dev/null +++ b/Sources/PushNotificationExtension/Models/PushStatusReportingError.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Errors surfaced by the push status reporting API. +public enum PushStatusReportingError: Error, Equatable, LocalizedError, Sendable { + + /// Encoding the push status request failed. + case requestEncodingFailed(String) + + /// Server response is not a valid HTTP response. + case invalidHTTPResponse(String) + + /// Network request failed before receiving an HTTP response. + case requestFailed(String) + + /// Server returned a non-success HTTP status code with optional response messages. + case httpStatus(Int, [String]) + + public var errorDescription: String? { + switch self { + case let .requestEncodingFailed(message): + return PushNotificationLocalization.requestEncodingFailed.format(message) + case .invalidHTTPResponse(let responseDescription): + return PushNotificationLocalization.invalidHTTPResponse.format(responseDescription) + case let .requestFailed(message): + return PushNotificationLocalization.requestFailed.format(message) + case let .httpStatus(statusCode, messages): + let details = messages.joined(separator: " ") + if details.isEmpty { + return PushNotificationLocalization.httpStatus.format(statusCode) + } + return PushNotificationLocalization.httpStatusWithDetails.format(statusCode, details) + } + } +} diff --git a/Sources/PushNotificationExtension/Models/PushStatusRequestBody.swift b/Sources/PushNotificationExtension/Models/PushStatusRequestBody.swift new file mode 100644 index 0000000..2b58f23 --- /dev/null +++ b/Sources/PushNotificationExtension/Models/PushStatusRequestBody.swift @@ -0,0 +1,27 @@ +import Foundation + +package struct PushStatusRequestBody: Codable, Equatable, Sendable { + package let changedAt: Int64 + package let pushId: String + package let status: PushNotificationStatus + package let buttonHash: String? + + private enum CodingKeys: String, CodingKey { + case changedAt = "changed_at" + case pushId = "push_id" + case status + case buttonHash = "button_hash" + } + + package init( + changedAt: Int64, + pushId: String, + status: PushNotificationStatus, + buttonHash: String? + ) { + self.changedAt = changedAt + self.pushId = pushId + self.status = status + self.buttonHash = buttonHash + } +} diff --git a/Sources/PushNotificationExtension/Models/PushStatusRetryUpdate.swift b/Sources/PushNotificationExtension/Models/PushStatusRetryUpdate.swift new file mode 100644 index 0000000..e09a1f8 --- /dev/null +++ b/Sources/PushNotificationExtension/Models/PushStatusRetryUpdate.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Identifies a persisted push status across retries and process restarts. Contains no site token. +public struct PushStatusRetryEvent: Equatable, Sendable { + public let id: UUID + public let pushID: String + public let status: PushNotificationStatus + public let changedAt: Int64 + public let buttonHash: String? + + package init(id: UUID, body: PushStatusRequestBody) { + self.id = id + self.pushID = body.pushId + self.status = body.status + self.changedAt = body.changedAt + self.buttonHash = body.buttonHash + } +} + +/// Diagnostics for statuses that entered the retry queue. Initial requests are not included. +/// Callbacks run on the retry queue, off the main thread, and should return promptly. +public enum PushStatusRetryUpdate: Equatable, Sendable { + /// Snapshot of an already pending status when the handler is assigned. + case pending(PushStatusRetryEvent, attempts: Int, nextAttemptAt: Date) + /// The first transient failure was saved for retry. + case queued(PushStatusRetryEvent, PushStatusReportingError) + /// A retry request is starting. Attempt numbers include the initial request. + case sending(PushStatusRetryEvent, attempt: Int) + /// The status is retained for a later attempt. + case retryScheduled(PushStatusRetryEvent, nextAttempt: Int, nextAttemptAt: Date, error: PushStatusReportingError) + /// The backend accepted this status, including acceptance by a separate duplicate report. + case delivered(PushStatusRetryEvent) + /// A permanent error ended retries, including rejection of a separate duplicate report. + case rejected(PushStatusRetryEvent, PushStatusReportingError) + /// The attempt limit was reached. The last error is unavailable after an interrupted final attempt. + case dropped(PushStatusRetryEvent, attempts: Int, lastError: PushStatusReportingError?) +} + +/// Owns one site-specific subscription without replacing subscriptions of other handlers. +package final class PushStatusRetryObservation { + private let id = UUID() + private let siteToken: String + private let queue: PushStatusRetryQueue + private let lock = NSLock() + private var handler: ((PushStatusRetryUpdate) -> Void)? + + package init(siteToken: String, queue: PushStatusRetryQueue) { + self.siteToken = siteToken + self.queue = queue + } + + package var onUpdate: ((PushStatusRetryUpdate) -> Void)? { + get { lock.withLock { handler } } + set { + lock.withLock { handler = newValue } + queue.observe(id: id, siteToken: siteToken, handler: newValue == nil ? nil : { [weak self] update in + guard let self else { return } + let callback = self.lock.withLock { self.handler } + callback?(update) + }) + } + } + + deinit { queue.observe(id: id, siteToken: siteToken, handler: nil) } +} diff --git a/Sources/PushNotificationExtension/ObservedPushNetworkSession.swift b/Sources/PushNotificationExtension/ObservedPushNetworkSession.swift new file mode 100644 index 0000000..6a26b19 --- /dev/null +++ b/Sources/PushNotificationExtension/ObservedPushNetworkSession.swift @@ -0,0 +1,39 @@ +import Foundation +import SDKConfiguration + +/// Observes the extension's requests without changing its timeouts or retry queue. +package struct ObservedPushNetworkSession: PushNetworkSession { + let session: any PushNetworkSession + let delivery: NetworkObservationDelivery + + init(session: any PushNetworkSession, observer: any CrmSdkNetworkObserver) { + self.session = session + self.delivery = NetworkObservationDelivery(observer: observer) + } + + package func dataTask( + with request: URLRequest, + completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) -> URLSessionDataTask { + let observation = delivery.begin(request: request) + let task = session.dataTask(with: request) { data, response, error in + observation.finish(response: response, data: data, error: error) + completionHandler(data, response, error) + } + if let session = session as? URLSession { observation.attachMetrics(to: task, in: session) } + return task + } + + package func downloadTask( + with url: URL, + completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void + ) -> URLSessionDownloadTask { + let observation = delivery.begin(request: URLRequest(url: url)) + let task = session.downloadTask(with: url) { url, response, error in + observation.finish(response: response, data: nil, error: error) + completionHandler(url, response, error) + } + if let session = session as? URLSession { observation.attachMetrics(to: task, in: session) } + return task + } +} diff --git a/Sources/PushNotificationExtension/PushMessageParser.swift b/Sources/PushNotificationExtension/PushMessageParser.swift new file mode 100644 index 0000000..e0ca9ef --- /dev/null +++ b/Sources/PushNotificationExtension/PushMessageParser.swift @@ -0,0 +1,205 @@ +import Foundation + +package enum PushMessageParser { + private static let crmPushIDUserInfoKey = "r_group_push" + private static let pushIDUserInfoKey = "push_id" + private static let customAttributePrefix = "ca_" + + package static func parseSilent(userInfo: [AnyHashable: Any]) -> PushMessage? { + guard + let aps = userInfo["aps"] as? [String: Any], + let contentAvailable = aps["content-available"] as? NSNumber, + contentAvailable.intValue == 1, + aps["alert"] == nil, + aps["sound"] == nil, + aps["badge"] == nil + else { + return nil + } + return PushMessageParser.parse(userInfo: userInfo) + } + + package static func parse( + userInfo: [AnyHashable: Any], + title notificationTitle: String? = nil, + body notificationBody: String? = nil + ) -> PushMessage? { + guard isValidFingerprint(userInfo: userInfo) else { + return nil + } + + guard + let pushId = stringValue(from: userInfo[pushIDUserInfoKey]), + isValidPushId(pushId) + else { + return nil + } + + return PushMessage( + pushId: pushId, + title: notificationTitle ?? stringValue(for: ["gcm.n.title", "gcm.notification.title", "title"], in: userInfo), + body: notificationBody ?? stringValue(for: ["gcm.n.body", "gcm.notification.body", "body"], in: userInfo), + imageURL: urlValue(for: ["image_url", "image", "gcm.n.image", "gcm.notification.image"], in: userInfo), + clickAction: { + let stringUrl = stringValue(for: ["click_action", "url", "link"], in: userInfo) + let attributes = customAttributes(from: userInfo) + if let stringUrl { + return ClickAction(stringUrl: stringUrl, customAttributes: attributes.isEmpty ? nil : attributes) + } + return nil + }(), + rgCategory: stringValue(for: ["r_group_category"], in: userInfo), + buttons: buttons(from: userInfo["buttons"]), + userInfo: userInfo + ) + } +} + +private extension PushMessageParser { + + static func isValidFingerprint(userInfo: [AnyHashable: Any]) -> Bool { + let value = userInfo[crmPushIDUserInfoKey] + + if let bool = value as? Bool { + return bool + } + + if let number = value as? Int { + return number == 1 + } + + if let number = value as? NSNumber { + return number.intValue == 1 + } + + if let string = value as? String { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "1" || normalized == "true" + } + + return false + } + + static func isValidPushId(_ pushId: String) -> Bool { + guard + let pushId = pushId.nilIfEmpty, + let separatorIndex = pushId.firstIndex(of: "_"), + separatorIndex != pushId.startIndex, + separatorIndex != pushId.index(before: pushId.endIndex) + else { + return false + } + + let appPart = pushId[.. [PushButton] { + guard + let string = stringValue(from: value), + let data = string.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data), + let dictionaries = json as? [[AnyHashable: Any]] + else { + return [] + } + + return dictionaries.compactMap { dictionary in + guard + let title = stringValue(from: dictionary["title"]), + let value = stringValue(from: dictionary["value"]) + else { + return nil + } + + return PushButton(title: title, value: value) + } + } + + static func customAttributes(from userInfo: [AnyHashable: Any]) -> [String: String] { + userInfo.reduce(into: [String: String]()) { result, element in + guard + let key = element.key.base as? String, + key.hasPrefix(customAttributePrefix) + else { + return + } + + let attributeKey = String(key.dropFirst(customAttributePrefix.count)) + guard + !attributeKey.isEmpty, + let value = customAttributeValue(from: element.value) + else { + return + } + + result[attributeKey] = value + } + } + + static func customAttributeValue(from value: Any) -> String? { + if let string = value as? String { + return string + } + + if let number = value as? NSNumber { + return number.stringValue + } + + return nil + } + + static func stringValue( + for keys: [String], + in dictionary: [AnyHashable: Any] + ) -> String? { + keys + .lazy + .compactMap { stringValue(from: dictionary[$0]) } + .first + } + + static func urlValue( + for keys: [String], + in dictionary: [AnyHashable: Any] + ) -> URL? { + keys + .lazy + .compactMap { stringValue(from: dictionary[$0]) } + .compactMap(validURL(from:)) + .first + } + + static func stringValue(from value: Any?) -> String? { + if let string = value as? String { + return string.nilIfEmpty + } + + if let number = value as? NSNumber { + return number.stringValue.nilIfEmpty + } + + return nil + } + + static func validURL(from string: String) -> URL? { + guard + let url = URL(string: string), + url.scheme?.nilIfEmpty != nil + else { + return nil + } + + return url + } +} + +private extension String { + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/PushNotificationExtension/PushNetworkSession.swift b/Sources/PushNotificationExtension/PushNetworkSession.swift new file mode 100644 index 0000000..610ccf8 --- /dev/null +++ b/Sources/PushNotificationExtension/PushNetworkSession.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Network operations shared by notification enrichment and status reporting. +package protocol PushNetworkSession: Sendable { + func dataTask( + with request: URLRequest, + completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) -> URLSessionDataTask + + func downloadTask( + with url: URL, + completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void + ) -> URLSessionDownloadTask +} + +extension URLSession: PushNetworkSession {} diff --git a/Sources/PushNotificationExtension/PushNotificationHandler.swift b/Sources/PushNotificationExtension/PushNotificationHandler.swift new file mode 100644 index 0000000..2a2f4c3 --- /dev/null +++ b/Sources/PushNotificationExtension/PushNotificationHandler.swift @@ -0,0 +1,205 @@ +import Foundation +import SDKConfiguration +import UserNotifications + +/// Notification service extension support without initializing the application SDK. +public final class PushNotificationHandler { + private static let buttonActionURLsUserInfoKey = "push_button_action_urls" + + private let siteToken: String + private let session: any PushNetworkSession + private let statusReporter: PushStatusReporter + private let retryObservation: PushStatusRetryObservation + + /// Receives the current retry queue and future updates for this site, off the main thread. + /// Includes statuses queued by other handlers in this process. Assign nil to unsubscribe. + public var onStatusRetryUpdate: ((PushStatusRetryUpdate) -> Void)? { + get { retryObservation.onUpdate } + set { retryObservation.onUpdate = newValue } + } + + /// Uses the same backend URL and site token as the application. + public convenience init( + siteToken: String, baseURL: String = SDKBackend.defaultBaseURL, + networkObserver: (any CrmSdkNetworkObserver)? = nil + ) { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 60 + self.init( + siteToken: siteToken, baseURL: baseURL, + session: URLSession(configuration: configuration), networkObserver: networkObserver + ) + } + + package init( + siteToken: String, + baseURL: String = SDKBackend.defaultBaseURL, + session: any PushNetworkSession, + retryQueue: PushStatusRetryQueue = .shared, + networkObserver: (any CrmSdkNetworkObserver)? = nil + ) { + self.siteToken = siteToken + let observedSession: any PushNetworkSession + if let networkObserver { + observedSession = ObservedPushNetworkSession(session: session, observer: networkObserver) + } else { + observedSession = session + } + self.session = observedSession + self.statusReporter = .init( + session: observedSession, retryQueue: retryQueue, + clientAPIEndpointURL: BackendEndpoints(baseURL: baseURL).clientAPIEndpointURL + ) + self.retryObservation = .init(siteToken: siteToken, queue: retryQueue) + } + + /// Reports delivery and enriches a RetailCRM notification with buttons and an optional image attachment. + /// + /// Returns false without calling completion for unrelated notifications. + /// + /// - Parameters: + /// - request: Notification request received by the service extension. + /// - content: Mutable notification content to enrich. + /// - completion: Receives enriched content only for a handled RetailCRM notification. + @discardableResult + public func handle( + request: UNNotificationRequest, + content: UNMutableNotificationContent, + completion: @escaping (UNMutableNotificationContent) -> Void + ) -> Bool { + guard let message = PushMessageParser.parse( + userInfo: request.content.userInfo, + title: request.content.title, + body: request.content.body + ) else { + return false + } + + var userInfo = content.userInfo + userInfo[PushNotificationMetadata.deliveredByExtensionUserInfoKey] = true + content.userInfo = userInfo + + let group = DispatchGroup() + group.enter() + statusReporter.reportStatus(.delivered, pushId: message.pushId, siteToken: siteToken) { _ in + group.leave() + } + + group.enter() + applyButtons(from: message, to: content) { + guard let imageURL = message.imageURL else { + group.leave() + return + } + + self.downloadImageAttachment(from: imageURL) { attachment in + if let attachment { + content.attachments = [attachment] + } + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + completion(content) + } + + return true + } + + private func downloadImageAttachment( + from imageURL: URL, + completion: @escaping (UNNotificationAttachment?) -> Void + ) { + session.downloadTask(with: imageURL) { temporaryURL, response, _ in + guard let temporaryURL else { + completion(nil) + return + } + + let fileExtension = self.fileExtension(for: imageURL, response: response) + let localURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(fileExtension) + + do { + try FileManager.default.moveItem(at: temporaryURL, to: localURL) + completion(try UNNotificationAttachment(identifier: "image", url: localURL)) + } catch { + completion(nil) + } + } + .resume() + } + + private func applyButtons( + from message: PushMessage, + to content: UNMutableNotificationContent, + completion: @escaping () -> Void + ) { + let buttons = message.buttons + guard !buttons.isEmpty else { + if let categoryIdentifier = message.rgCategory { + content.categoryIdentifier = categoryIdentifier + } + completion() + return + } + + let categoryIdentifier = message.pushId + + let category = UNNotificationCategory( + identifier: categoryIdentifier, + actions: buttons.enumerated().map { index, button in + UNNotificationAction( + identifier: Self.actionIdentifier(categoryIdentifier: categoryIdentifier, index: index), + title: button.title, + options: [.foreground] + ) + }, + intentIdentifiers: [], + options: [.customDismissAction] + ) + + content.categoryIdentifier = categoryIdentifier + var userInfo = content.userInfo + userInfo[Self.buttonActionURLsUserInfoKey] = Dictionary( + uniqueKeysWithValues: buttons.enumerated().map { index, button in + (Self.actionIdentifier(categoryIdentifier: categoryIdentifier, index: index), button.value) + } + ) + content.userInfo = userInfo + + UNUserNotificationCenter.current().getNotificationCategories { categories in + var updatedCategories = categories.filter { + $0.identifier != categoryIdentifier + } + updatedCategories.insert(category) + UNUserNotificationCenter.current().setNotificationCategories(updatedCategories) + completion() + } + } + + private static func actionIdentifier(categoryIdentifier: String, index: Int) -> String { + "\(categoryIdentifier)_\(index)" + } + + private func fileExtension(for url: URL, response: URLResponse?) -> String { + let pathExtension = url.pathExtension.lowercased() + if ["jpg", "jpeg", "png", "gif"].contains(pathExtension) { + return pathExtension + } + + switch response?.mimeType?.lowercased() { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/gif": + return "gif" + default: + return "jpg" + } + } +} diff --git a/Sources/PushNotificationExtension/PushNotificationLocalization.swift b/Sources/PushNotificationExtension/PushNotificationLocalization.swift new file mode 100644 index 0000000..ded0a32 --- /dev/null +++ b/Sources/PushNotificationExtension/PushNotificationLocalization.swift @@ -0,0 +1,21 @@ +import Foundation + +package enum PushNotificationLocalization: String { + case httpStatus = "push_status.error.http_status" + case httpStatusWithDetails = "push_status.error.http_status_with_details" + case invalidHTTPResponse = "push_status.error.invalid_http_response" + case requestEncodingFailed = "push_status.error.request_encoding_failed" + case requestFailed = "push_status.error.request_failed" + + package var localized: String { + NSLocalizedString( + rawValue, + bundle: .module, + comment: "" + ) + } + + package func format(_ arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} diff --git a/Sources/PushNotificationExtension/PushNotificationMetadata.swift b/Sources/PushNotificationExtension/PushNotificationMetadata.swift new file mode 100644 index 0000000..1305665 --- /dev/null +++ b/Sources/PushNotificationExtension/PushNotificationMetadata.swift @@ -0,0 +1,3 @@ +package enum PushNotificationMetadata { + package static let deliveredByExtensionUserInfoKey = "retailcrm_delivered_by_extension" +} diff --git a/Sources/PushNotificationExtension/PushStatusReporter.swift b/Sources/PushNotificationExtension/PushStatusReporter.swift new file mode 100644 index 0000000..c852fe5 --- /dev/null +++ b/Sources/PushNotificationExtension/PushStatusReporter.swift @@ -0,0 +1,146 @@ +import Foundation +import SDKConfiguration + +package struct PushStatusReporter: Sendable { + + private let session: any PushNetworkSession + private let retryQueue: PushStatusRetryQueue + private let clientAPIEndpointURL: URL + + package init( + session: any PushNetworkSession, + retryQueue: PushStatusRetryQueue = .shared, + clientAPIEndpointURL: URL = BackendEndpoints().clientAPIEndpointURL + ) { + self.session = session + self.clientAPIEndpointURL = clientAPIEndpointURL + self.retryQueue = retryQueue + retryQueue.resume(using: session) + } + + package func reportStatus( + _ status: PushNotificationStatus, + pushId: String, + buttonHash: String? = nil, + siteToken: String, + completion: @escaping (Result) -> Void = { _ in } + ) { + let requestBody = PushStatusRequestBody( + changedAt: Int64(Date().timeIntervalSince1970), + pushId: pushId, + status: status, + buttonHash: buttonHash + ) + + Self.send( + requestBody, siteToken: siteToken, clientAPIEndpointURL: clientAPIEndpointURL, session: session + ) { result, retryAfter in + if case .failure(let error) = result, error.isRetryable { + retryQueue.enqueue( + requestBody, siteToken: siteToken, clientAPIEndpointURL: clientAPIEndpointURL, + retryAfter: retryAfter, error: error + ) { + completion(result) + } + } else { + retryQueue.discard( + requestBody, siteToken: siteToken, clientAPIEndpointURL: clientAPIEndpointURL, result: result + ) { completion(result) } + } + } + } + + /// Performs one attempt without changing the original event time or scheduling another request. + package static func send( + _ body: PushStatusRequestBody, + siteToken: String, + clientAPIEndpointURL: URL = BackendEndpoints().clientAPIEndpointURL, + session: any PushNetworkSession, + completion: @escaping (Result, TimeInterval?) -> Void + ) { + + var request = URLRequest( + url: clientAPIEndpointURL.appendingPathComponent("push-tokens/set-status") + ) + request.httpMethod = "POST" + do { + request.httpBody = try JSONEncoder().encode(body) + } catch { + completion(.failure(.requestEncodingFailed(error.localizedDescription)), nil) + return + } + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(siteToken, forHTTPHeaderField: "X-Site-Token") + + let task = session.dataTask(with: request) { data, response, error in + if let error { + completion(.failure(.requestFailed(error.localizedDescription)), nil) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(.invalidHTTPResponse(String(describing: response))), nil) + return + } + + guard (200..<300).contains(httpResponse.statusCode) else { + let responseData = data ?? Data() + var messages = (try? JSONDecoder().decode(PushStatusErrorResponse.self, from: responseData))?.messages ?? [] + if messages.isEmpty, + let message = String(data: responseData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { + messages = [message] + } + completion(.failure(.httpStatus(httpResponse.statusCode, messages)), retryAfter(from: httpResponse)) + return + } + + completion(.success(()), nil) + } + task.resume() + } + + private static func retryAfter(from response: HTTPURLResponse) -> TimeInterval? { + guard let value = response.value(forHTTPHeaderField: "Retry-After") else { return nil } + if let seconds = TimeInterval(value), seconds.isFinite { return max(0, seconds) } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z" + return formatter.date(from: value).map { max(0, $0.timeIntervalSinceNow) } + } +} + +package extension PushStatusReportingError { + var isRetryable: Bool { + switch self { + case .requestFailed, .invalidHTTPResponse: + return true + case .httpStatus(let code, _): + return code == 408 || code == 425 || code == 429 || (500..<600).contains(code) + case .requestEncodingFailed: + return false + } + } +} + +private struct PushStatusErrorResponse: Decodable { + let error: String? + let errors: [String]? + + var messages: [String] { + if let errors, !errors.isEmpty { + return errors + } + + if + let error = error?.trimmingCharacters(in: .whitespacesAndNewlines), + !error.isEmpty + { + return [error] + } + + return [] + } +} diff --git a/Sources/PushNotificationExtension/PushStatusRetryQueue.swift b/Sources/PushNotificationExtension/PushStatusRetryQueue.swift new file mode 100644 index 0000000..c132771 --- /dev/null +++ b/Sources/PushNotificationExtension/PushStatusRetryQueue.swift @@ -0,0 +1,229 @@ +import Foundation +import SDKConfiguration +import Network + +/// Persistent retries within the current application or notification-extension container. +/// The first attempt's completion is independent of retries and does not hold notification delivery. +package final class PushStatusRetryQueue: @unchecked Sendable { + package static let shared = PushStatusRetryQueue() + + private let queue = DispatchQueue(label: "com.retailcrm.push-status-retries") + private let storage: PushStatusRetryStorage + private let baseDelay: TimeInterval + private let maxAttempts: Int + private let monitor: NWPathMonitor? + private var session: (any PushNetworkSession)? + private var pending: [UUID: PendingPushStatus] = [:] + private var isSending = false + private var isNetworkAvailable = true + private var scheduled: DispatchWorkItem? + private var observers: [UUID: (siteToken: String, handler: (PushStatusRetryUpdate) -> Void)] = [:] + + package init( + storage: PushStatusRetryStorage = .init(), + baseDelay: TimeInterval = 10, + maxAttempts: Int = 5, + monitorsNetwork: Bool = true + ) { + self.storage = storage + self.baseDelay = baseDelay + self.maxAttempts = max(1, maxAttempts) + self.isNetworkAvailable = !monitorsNetwork + self.monitor = monitorsNetwork ? NWPathMonitor() : nil + do { + for entry in try storage.loadPending() { pending[entry.id] = entry } + } catch { + NSLog("RetailCRM push status retries could not be restored: %@", error.localizedDescription) + } + monitor?.pathUpdateHandler = { [weak self] path in + guard let self else { return } + self.queue.async { + self.isNetworkAvailable = path.status == .satisfied + self.scheduleNext() + } + } + monitor?.start(queue: queue) + } + + deinit { + scheduled?.cancel() + monitor?.cancel() + } + + /// Restores retry scheduling when a reporter is created after a process restart. + package func resume(using session: any PushNetworkSession) { + queue.async { + self.session = session + self.scheduleNext() + } + } + + package func observe(id: UUID, siteToken: String, handler: ((PushStatusRetryUpdate) -> Void)?) { + queue.async { + guard let handler else { + self.observers.removeValue(forKey: id) + return + } + self.observers[id] = (siteToken, handler) + for entry in self.pending.values.filter({ $0.siteToken == siteToken }) + .sorted(by: { $0.nextAttemptAt < $1.nextAttemptAt }) { + handler(.pending(entry.event, attempts: entry.attempts, nextAttemptAt: entry.nextAttemptAt)) + } + } + } + + private func publish(_ update: PushStatusRetryUpdate, siteToken: String) { + for observer in observers.values where observer.siteToken == siteToken { + observer.handler(update) + } + } + + package func enqueue( + _ body: PushStatusRequestBody, + siteToken: String, + clientAPIEndpointURL: URL = BackendEndpoints().clientAPIEndpointURL, + retryAfter: TimeInterval?, + error: PushStatusReportingError, + completion: @escaping () -> Void + ) { + queue.async { + if self.maxAttempts > 1 { + // Keep an existing retry's original timestamp, delay and attempt budget. + if !self.pending.values.contains(where: { + $0.clientAPIEndpointURL == clientAPIEndpointURL + && $0.siteToken == siteToken && $0.body.pushId == body.pushId + && $0.body.status == body.status && $0.body.buttonHash == body.buttonHash + }) { + let entry = PendingPushStatus( + id: UUID(), body: body, siteToken: siteToken, + clientAPIEndpointURL: clientAPIEndpointURL, attempts: 1, + nextAttemptAt: Date().addingTimeInterval(max(self.baseDelay, retryAfter ?? 0)) + ) + self.save(entry) + self.publish(.queued(entry.event, error), siteToken: siteToken) + self.publish(.retryScheduled( + entry.event, nextAttempt: 2, nextAttemptAt: entry.nextAttemptAt, error: error + ), siteToken: siteToken) + } + self.scheduleNext() + } + completion() + } + } + + /// A successful or permanent result for the same status makes its queued retry unnecessary. + package func discard( + _ body: PushStatusRequestBody, + siteToken: String, + clientAPIEndpointURL: URL = BackendEndpoints().clientAPIEndpointURL, + result: Result, + completion: @escaping () -> Void + ) { + queue.async { + let ids = self.pending.values.filter { + $0.clientAPIEndpointURL == clientAPIEndpointURL + && $0.siteToken == siteToken && $0.body.pushId == body.pushId + && $0.body.status == body.status && $0.body.buttonHash == body.buttonHash + }.map(\.id) + for id in ids { + guard let entry = self.pending[id] else { continue } + self.remove(id) + switch result { + case .success: + self.publish(.delivered(entry.event), siteToken: siteToken) + case .failure(let error): + self.publish(.rejected(entry.event, error), siteToken: siteToken) + } + } + self.scheduleNext() + completion() + } + } + + private func scheduleNext() { + scheduled?.cancel() + scheduled = nil + guard !isSending, isNetworkAvailable, session != nil, + let next = pending.values.min(by: { $0.nextAttemptAt < $1.nextAttemptAt }) else { return } + let work = DispatchWorkItem { [weak self] in self?.sendNext() } + scheduled = work + queue.asyncAfter(deadline: .now() + max(0, next.nextAttemptAt.timeIntervalSinceNow), execute: work) + } + + private func sendNext() { + guard !isSending, isNetworkAvailable, let session, + var entry = pending.values.filter({ $0.nextAttemptAt <= Date() }) + .min(by: { $0.nextAttemptAt < $1.nextAttemptAt }) else { + scheduleNext() + return + } + guard entry.attempts < maxAttempts else { + remove(entry.id) + publish(.dropped(entry.event, attempts: entry.attempts, lastError: nil), siteToken: entry.siteToken) + scheduleNext() + return + } + isSending = true + entry.attempts += 1 + // Persist the attempt budget before starting the request. + entry.nextAttemptAt = Date().addingTimeInterval(baseDelay * pow(2, Double(entry.attempts - 1))) + save(entry) + publish(.sending(entry.event, attempt: entry.attempts), siteToken: entry.siteToken) + let attempted = entry + PushStatusReporter.send( + entry.body, siteToken: entry.siteToken, + clientAPIEndpointURL: entry.clientAPIEndpointURL, + session: session + ) { result, retryAfter in + self.queue.async { + self.isSending = false + guard self.pending[attempted.id] != nil else { + self.scheduleNext() + return + } + if case .failure(let error) = result, error.isRetryable, attempted.attempts < self.maxAttempts { + var retry = attempted + retry.nextAttemptAt = Date().addingTimeInterval( + max(self.baseDelay * pow(2, Double(retry.attempts - 1)), retryAfter ?? 0) + ) + self.save(retry) + self.publish(.retryScheduled( + retry.event, nextAttempt: retry.attempts + 1, + nextAttemptAt: retry.nextAttemptAt, error: error + ), siteToken: retry.siteToken) + } else { + self.remove(attempted.id) + switch result { + case .success: + self.publish(.delivered(attempted.event), siteToken: attempted.siteToken) + case .failure(let error) where error.isRetryable: + self.publish(.dropped( + attempted.event, attempts: attempted.attempts, lastError: error + ), siteToken: attempted.siteToken) + case .failure(let error): + self.publish(.rejected(attempted.event, error), siteToken: attempted.siteToken) + } + } + self.scheduleNext() + } + } + } + + private func save(_ entry: PendingPushStatus) { + pending[entry.id] = entry + do { + try storage.save(entry) + } catch { + NSLog("RetailCRM push status retry could not be saved: %@", error.localizedDescription) + } + } + + private func remove(_ id: UUID) { + pending.removeValue(forKey: id) + do { + try storage.remove(id) + } catch { + NSLog("RetailCRM push status retry could not be removed: %@", error.localizedDescription) + } + } +} diff --git a/Sources/PushNotificationExtension/PushStatusRetryStorage.swift b/Sources/PushNotificationExtension/PushStatusRetryStorage.swift new file mode 100644 index 0000000..eb7de5d --- /dev/null +++ b/Sources/PushNotificationExtension/PushStatusRetryStorage.swift @@ -0,0 +1,109 @@ +import Foundation +import SQLiteStorage + +package struct PendingPushStatus: Equatable { + package let id: UUID + package let body: PushStatusRequestBody + package let siteToken: String + package let clientAPIEndpointURL: URL + package var attempts: Int + package var nextAttemptAt: Date + + package var event: PushStatusRetryEvent { .init(id: id, body: body) } + + package init( + id: UUID, + body: PushStatusRequestBody, + siteToken: String, + clientAPIEndpointURL: URL, + attempts: Int, + nextAttemptAt: Date + ) { + self.id = id + self.body = body + self.siteToken = siteToken + self.clientAPIEndpointURL = clientAPIEndpointURL + self.attempts = attempts + self.nextAttemptAt = nextAttemptAt + } +} + +/// Uses Application Support in the current container; no App Group is involved. +package final class PushStatusRetryStorage: @unchecked Sendable { + private let databaseURL: URL? + private let lock = NSLock() + private var database: SQLiteDatabase? + + package init(databaseURL: URL? = nil) { + self.databaseURL = databaseURL + } + + package func loadPending() throws -> [PendingPushStatus] { + try connection().withConnection { connection in + try connection.query(""" + SELECT id, body, site_token, endpoint, attempts, next_attempt_at + FROM push_status_retries ORDER BY next_attempt_at, id; + """).map { row in + guard let id = row[0].string.flatMap(UUID.init(uuidString:)), + let body = row[1].data, let site = row[2].string, + let endpoint = row[3].string.flatMap(URL.init(string:)), + let attempts = row[4].integer, let nextAttempt = row[5].double else { + throw SQLiteStorageError(message: "Invalid push status retry record.") + } + return PendingPushStatus( + id: id, body: try JSONDecoder().decode(PushStatusRequestBody.self, from: body), + siteToken: site, clientAPIEndpointURL: endpoint, attempts: Int(attempts), + nextAttemptAt: Date(timeIntervalSince1970: nextAttempt) + ) + } + } + } + + package func save(_ entry: PendingPushStatus) throws { + try connection().withConnection { connection in + try connection.execute(""" + INSERT INTO push_status_retries + (id, body, site_token, endpoint, push_id, status, button_hash, attempts, next_attempt_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET attempts = excluded.attempts, next_attempt_at = excluded.next_attempt_at; + """, bindings: [ + .text(entry.id.uuidString), .blob(try JSONEncoder().encode(entry.body)), .text(entry.siteToken), + .text(entry.clientAPIEndpointURL.absoluteString), .text(entry.body.pushId), .text(entry.body.status.rawValue), + entry.body.buttonHash.map(SQLiteValue.text) ?? .null, .integer(Int64(entry.attempts)), + .real(entry.nextAttemptAt.timeIntervalSince1970) + ]) + } + } + + package func remove(_ id: UUID) throws { + try connection().withConnection { connection in + try connection.execute("DELETE FROM push_status_retries WHERE id = ?;", bindings: [.text(id.uuidString)]) + } + } + + private func connection() throws -> SQLiteDatabase { + lock.lock() + defer { lock.unlock() } + if let database { return database } + let url = try databaseURL ?? SQLiteDatabase.applicationSupportURL(fileName: "crm_push_status_retries.sqlite") + let opened = try SQLiteDatabase(url: url) + try opened.migrate([""" + CREATE TABLE push_status_retries ( + id TEXT PRIMARY KEY NOT NULL, + body BLOB NOT NULL, + site_token TEXT NOT NULL, + endpoint TEXT NOT NULL, + push_id TEXT NOT NULL, + status TEXT NOT NULL, + button_hash TEXT, + attempts INTEGER NOT NULL, + next_attempt_at REAL NOT NULL + ); + CREATE UNIQUE INDEX push_status_retries_identity ON push_status_retries + (site_token, endpoint, push_id, status, COALESCE(button_hash, ''), (button_hash IS NULL)); + CREATE INDEX push_status_retries_next_attempt ON push_status_retries(next_attempt_at); + """]) + database = opened + return opened + } +} diff --git a/Sources/PushNotificationExtension/Resources/en.lproj/Localizable.strings b/Sources/PushNotificationExtension/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..d0720f2 --- /dev/null +++ b/Sources/PushNotificationExtension/Resources/en.lproj/Localizable.strings @@ -0,0 +1,5 @@ +"push_status.error.http_status" = "Push status request failed with HTTP status %d."; +"push_status.error.http_status_with_details" = "Push status request failed with HTTP status %d: %@"; +"push_status.error.invalid_http_response" = "Push status response is not a valid HTTP response: %@"; +"push_status.error.request_encoding_failed" = "Push status request could not be encoded: %@"; +"push_status.error.request_failed" = "Push status request failed: %@"; diff --git a/Sources/PushNotificationExtension/Resources/es.lproj/Localizable.strings b/Sources/PushNotificationExtension/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..ec06f0b --- /dev/null +++ b/Sources/PushNotificationExtension/Resources/es.lproj/Localizable.strings @@ -0,0 +1,5 @@ +"push_status.error.http_status" = "La solicitud del estado push falló con el estado HTTP %d."; +"push_status.error.http_status_with_details" = "La solicitud del estado push falló con el estado HTTP %d: %@"; +"push_status.error.invalid_http_response" = "La respuesta del estado push no es una respuesta HTTP válida: %@"; +"push_status.error.request_encoding_failed" = "No se pudo codificar la solicitud del estado push: %@"; +"push_status.error.request_failed" = "La solicitud del estado push falló: %@"; diff --git a/Sources/PushNotificationExtension/Resources/ru.lproj/Localizable.strings b/Sources/PushNotificationExtension/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000..4fc98d5 --- /dev/null +++ b/Sources/PushNotificationExtension/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,5 @@ +"push_status.error.http_status" = "Отправка статуса push-уведомления завершилась с HTTP-статусом %d."; +"push_status.error.http_status_with_details" = "Отправка статуса push-уведомления завершилась с HTTP-статусом %d: %@"; +"push_status.error.invalid_http_response" = "Ответ отправки статуса push-уведомления не является корректным HTTP-ответом: %@"; +"push_status.error.request_encoding_failed" = "Не удалось закодировать запрос отправки статуса push-уведомления: %@"; +"push_status.error.request_failed" = "Отправка статуса push-уведомления завершилась ошибкой: %@"; diff --git a/Sources/PushNotificationExtension/SDKResourceBundle.swift b/Sources/PushNotificationExtension/SDKResourceBundle.swift new file mode 100644 index 0000000..4d0714b --- /dev/null +++ b/Sources/PushNotificationExtension/SDKResourceBundle.swift @@ -0,0 +1,18 @@ +#if !SWIFT_PACKAGE +import Foundation + +private final class SDKResourceBundleToken {} + +extension Bundle { + static let module: Bundle = { + let moduleBundle = Bundle(for: SDKResourceBundleToken.self) + for container in [moduleBundle, Bundle.main] { + if let url = container.url(forResource: "SimlaPushNotificationExtension", withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } + } + fatalError("Missing SimlaPushNotificationExtension.bundle") + }() +} +#endif diff --git a/Sources/PushService/Components/PushLocalization.swift b/Sources/PushService/Components/PushLocalization.swift new file mode 100644 index 0000000..d4ee148 --- /dev/null +++ b/Sources/PushService/Components/PushLocalization.swift @@ -0,0 +1,35 @@ +import Foundation + +package enum PushLocalization: String { + + case operationSuperseded = "push.error.operation_superseded" + case authorizationDenied = "push.error.authorization_denied" + case emptyPushToken = "push.error.empty_push_token" + case emptyFCMToken = "push.error.empty_fcm_token" + case emptyFID = "push.error.empty_fid" + case emptyFirebaseProjectID = "push.error.empty_firebase_project_id" + case firebaseInstallationIDDisabled = "push.error.firebase_installation_id_disabled" + case missingCustomerIdentifier = "push.error.missing_customer_identifier" + case invalidHTTPResponse = "push.error.invalid_http_response" + case requestEncodingFailed = "push.error.request_encoding_failed" + case requestFailed = "push.error.request_failed" + case httpStatus = "push.error.http_status" + case httpStatusWithDetails = "push.error.http_status_with_details" + case statusReportingInvalidHTTPResponse = "push.error.status_reporting_invalid_http_response" + case statusReportingRequestEncodingFailed = "push.error.status_reporting_request_encoding_failed" + case statusReportingRequestFailed = "push.error.status_reporting_request_failed" + case statusReportingHTTPStatus = "push.error.status_reporting_http_status" + case statusReportingHTTPStatusWithDetails = "push.error.status_reporting_http_status_with_details" + + package var localized: String { + NSLocalizedString( + rawValue, + bundle: .module, + comment: "" + ) + } + + package func format(_ arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} diff --git a/Sources/PushService/CrmSdk+PushService.swift b/Sources/PushService/CrmSdk+PushService.swift new file mode 100644 index 0000000..b26374d --- /dev/null +++ b/Sources/PushService/CrmSdk+PushService.swift @@ -0,0 +1,32 @@ +import Core + +public extension CrmSdk { + + /// Shared push module for applications that manage Firebase and notification delegates. + var pushService: PushService { + sharedPushService(for: .direct) + } +} + +private extension CrmSdk { + + func sharedPushService(for kind: ConsultantPushModuleKind) -> PushService { + claimPushModule(kind) + return module(PushService.self) { + let pushService = PushService( + sessionRuntime: sessionRuntime, + pushHandler: .init( + siteToken: sessionRuntime.config.siteToken, + clientAPIEndpointURL: sessionRuntime.config.endpoints.clientAPIEndpointURL + ) + ) + + Task { + await sessionRuntime.addLifecycleListener(pushService) + } + + return pushService + } + } +} + diff --git a/Sources/PushService/Models/NotificationAction.swift b/Sources/PushService/Models/NotificationAction.swift new file mode 100644 index 0000000..cd3541c --- /dev/null +++ b/Sources/PushService/Models/NotificationAction.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Data passed to the application when a RetailCRM notification or its button is opened. +public struct NotificationAction { + /// `click_action`, fallback `url` or `link`, or tapped button value; `nil` if absent. + public let urlString: String? + /// String and numeric push fields prefixed with `ca_`, without the prefix; `nil` if none were valid. + public let customAttributes: [String: String]? + + /// Creates a notification action without converting the action string to `URL`. + /// + /// - Parameters: + /// - urlString: Action string supplied by the notification payload; it may be absent. + /// - customAttributes: Optional custom attributes from the notification payload. + public init(urlString: String?, customAttributes: [String: String]?) { + self.urlString = urlString + self.customAttributes = customAttributes + } +} diff --git a/Sources/PushService/Models/PushServiceError.swift b/Sources/PushService/Models/PushServiceError.swift new file mode 100644 index 0000000..0531b93 --- /dev/null +++ b/Sources/PushService/Models/PushServiceError.swift @@ -0,0 +1,157 @@ +import Foundation + +/// Errors surfaced by push core. +public enum PushServiceError: Error, Equatable, LocalizedError, Sendable { + + /// Reasons why a notification status could not be reported. + public enum StatusReportingFailure: Equatable, Sendable { + /// The status request could not be encoded. + case requestEncodingFailed(String) + /// The response was not a valid HTTP response. + case invalidHTTPResponse(String) + /// The request failed before an HTTP response was received. + case requestFailed(String) + /// The server returned an HTTP status and optional messages. + case httpStatus(Int, [String]) + } + + /// Reading or writing the local push registration database failed. + case storage(String) + + /// A newer category list replaced a pending registration before it could start. + case operationSuperseded + + /// The customer identity is invalid. + case invalidCustomerIdentity(String) + + /// The user denied authorization to display notifications. + case authorizationDenied + + /// A push identifier is not yet available. + case emptyPushToken + + /// FCM token is empty after trimming whitespace. + case emptyFCMToken + + /// Firebase installation ID is empty after trimming whitespace. + case emptyFID + + /// Firebase project ID is unavailable from configured Firebase options. + case emptyFirebaseProjectID + + /// Firebase Messaging installation ID delivery is disabled in app configuration. + case firebaseInstallationIDDisabled + + /// Customer ID or external customer ID is missing. + case missingCustomerIdentifier + + /// Server response is not a valid HTTP response. + case invalidHTTPResponse(String) + + /// Encoding the push token registration request failed. + case requestEncodingFailed(String) + + /// Network request failed before receiving an HTTP response. + case requestFailed(String) + + /// Server returned a non-success HTTP status code with optional response messages. + case httpStatus(Int, [String]) + + /// Reporting a notification status to the backend failed. + case statusReporting(StatusReportingFailure) + + /// Firebase Messaging registration or FCM token retrieval failed. + case firebaseMessaging(Error) + + /// Firebase Installations failed to provide an installation ID. + case firebaseInstallations(Error) + + /// Localized human-readable error description. + public var errorDescription: String? { + switch self { + case .operationSuperseded: + return PushLocalization.operationSuperseded.localized + case .storage(let message), .invalidCustomerIdentity(let message): + return message + case .authorizationDenied: + return PushLocalization.authorizationDenied.localized + case .emptyPushToken: + return PushLocalization.emptyPushToken.localized + case .emptyFCMToken: + return PushLocalization.emptyFCMToken.localized + case .emptyFID: + return PushLocalization.emptyFID.localized + case let .firebaseMessaging(error): + return error.localizedDescription + case let .firebaseInstallations(error): + return error.localizedDescription + case .emptyFirebaseProjectID: + return PushLocalization.emptyFirebaseProjectID.localized + case .firebaseInstallationIDDisabled: + return PushLocalization.firebaseInstallationIDDisabled.localized + case .missingCustomerIdentifier: + return PushLocalization.missingCustomerIdentifier.localized + case .invalidHTTPResponse(let responseDescription): + return PushLocalization.invalidHTTPResponse.format(responseDescription) + case let .requestEncodingFailed(message): + return PushLocalization.requestEncodingFailed.format(message) + case let .requestFailed(message): + return PushLocalization.requestFailed.format(message) + case let .httpStatus(statusCode, messages): + let details = messages.joined(separator: " ") + if details.isEmpty { + return PushLocalization.httpStatus.format(statusCode) + } + return PushLocalization.httpStatusWithDetails.format(statusCode, details) + case let .statusReporting(reason): + switch reason { + case let .requestEncodingFailed(message): + return PushLocalization.statusReportingRequestEncodingFailed.format(message) + case let .invalidHTTPResponse(responseDescription): + return PushLocalization.statusReportingInvalidHTTPResponse.format(responseDescription) + case let .requestFailed(message): + return PushLocalization.statusReportingRequestFailed.format(message) + case let .httpStatus(statusCode, messages): + let details = messages.joined(separator: " ") + if details.isEmpty { + return PushLocalization.statusReportingHTTPStatus.format(statusCode) + } + return PushLocalization.statusReportingHTTPStatusWithDetails.format(statusCode, details) + } + } + } + + /// Compares push service errors, including their associated values. + /// + /// - Parameters: + /// - lhs: First error to compare. + /// - rhs: Second error to compare. + public static func == (lhs: PushServiceError, rhs: PushServiceError) -> Bool { + switch (lhs, rhs) { + case let (.storage(left), .storage(right)), + let (.invalidCustomerIdentity(left), .invalidCustomerIdentity(right)), + let (.invalidHTTPResponse(left), .invalidHTTPResponse(right)), + let (.requestEncodingFailed(left), .requestEncodingFailed(right)), + let (.requestFailed(left), .requestFailed(right)): + return left == right + case let (.httpStatus(leftCode, leftMessages), .httpStatus(rightCode, rightMessages)): + return leftCode == rightCode && leftMessages == rightMessages + case let (.statusReporting(left), .statusReporting(right)): + return left == right + case let (.firebaseMessaging(left), .firebaseMessaging(right)), + let (.firebaseInstallations(left), .firebaseInstallations(right)): + return (left as NSError) == (right as NSError) + case (.operationSuperseded, .operationSuperseded), + (.authorizationDenied, .authorizationDenied), + (.emptyPushToken, .emptyPushToken), + (.emptyFCMToken, .emptyFCMToken), + (.emptyFID, .emptyFID), + (.emptyFirebaseProjectID, .emptyFirebaseProjectID), + (.firebaseInstallationIDDisabled, .firebaseInstallationIDDisabled), + (.missingCustomerIdentifier, .missingCustomerIdentifier): + return true + default: + return false + } + } +} diff --git a/Sources/PushService/Models/PushToken.swift b/Sources/PushService/Models/PushToken.swift new file mode 100644 index 0000000..dcc6a4b --- /dev/null +++ b/Sources/PushService/Models/PushToken.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Push identifier received from Firebase. +public enum PushToken: Equatable, Sendable { + /// Firebase installation ID. + case fid(String) + + /// Firebase Cloud Messaging registration token. + case fcm(String) + + package var fidValue: String? { + if case .fid(let value) = self { + return value + } + return nil + } + + package var fcmValue: String? { + if case .fcm(let value) = self { + return value + } + return nil + } +} + +package extension PushToken { + + func normalizedToken() throws -> PushToken { + switch self { + case let .fid(value): + guard let value = value.nilIfEmpty else { + throw PushServiceError.emptyFID + } + return .fid(value) + case let .fcm(value): + guard let value = value.nilIfEmpty else { + throw PushServiceError.emptyFCMToken + } + return .fcm(value) + } + } + +} diff --git a/Sources/PushService/Models/PushTokenRequestBody.swift b/Sources/PushService/Models/PushTokenRequestBody.swift new file mode 100644 index 0000000..4ff6246 --- /dev/null +++ b/Sources/PushService/Models/PushTokenRequestBody.swift @@ -0,0 +1,31 @@ +import Foundation + +package struct PushTokenRequestBody: Encodable, Equatable, Sendable { + package let customerId: Int64? + package let customerExternalId: String? + package let crmSiteCode: String? + package let token: String? + package let fid: String? + package let type: String + package let deviceType: String + package let categories: [String] + + package init( + customerId: Int64?, + customerExternalId: String?, + crmSiteCode: String?, + token: String?, + fid: String?, + firebaseProjectId: String, + categories: [String] + ) { + self.customerId = customerId + self.customerExternalId = customerExternalId + self.crmSiteCode = crmSiteCode + self.token = token + self.fid = fid + self.type = firebaseProjectId + self.deviceType = "ios" + self.categories = categories + } +} diff --git a/Sources/PushService/PushAppNetworkSession.swift b/Sources/PushService/PushAppNetworkSession.swift new file mode 100644 index 0000000..0bd78bd --- /dev/null +++ b/Sources/PushService/PushAppNetworkSession.swift @@ -0,0 +1,32 @@ +import Core +import Foundation +import PushNotificationExtension + +/// Applies the application's SDK timeouts to push requests. +package struct PushAppNetworkSession: PushNetworkSession { + private let session: URLSession + + package init(session: URLSession) { + self.session = session + } + + package func dataTask( + with request: URLRequest, + completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) -> URLSessionDataTask { + NetworkSession.dataTask( + with: request, in: session, + completionHandler: completionHandler + ) + } + + package func downloadTask( + with url: URL, + completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void + ) -> URLSessionDownloadTask { + NetworkSession.downloadTask( + with: url, in: session, + completionHandler: completionHandler + ) + } +} diff --git a/Sources/PushService/PushHandler.swift b/Sources/PushService/PushHandler.swift new file mode 100644 index 0000000..a2ae2be --- /dev/null +++ b/Sources/PushService/PushHandler.swift @@ -0,0 +1,150 @@ +import Core +import CryptoKit +import PushNotificationExtension +import Foundation +import UserNotifications + +package enum PushNotificationHandlingResult { + case handled(PushMessage) + case notHandled +} + +package final class PushHandler { + private let siteToken: String + private let statusReporter: PushStatusReporter + private let retryObservation: PushStatusRetryObservation + + package var onStatusRetryUpdate: ((PushStatusRetryUpdate) -> Void)? { + get { retryObservation.onUpdate } + set { retryObservation.onUpdate = newValue } + } + + package init( + siteToken: String, + clientAPIEndpointURL: URL = APIEndpoints().clientAPIEndpointURL, + session: URLSession = NetworkSession.shared, + retryQueue: PushStatusRetryQueue = .shared + ) { + let networkSession = PushAppNetworkSession(session: session) + self.siteToken = siteToken + self.statusReporter = .init( + session: networkSession, retryQueue: retryQueue, clientAPIEndpointURL: clientAPIEndpointURL + ) + self.retryObservation = .init(siteToken: siteToken, queue: retryQueue) + } + + @discardableResult + package func handleSilentNotification( + _ userInfo: [AnyHashable: Any], + completion: @escaping (Result) -> Void + ) -> Bool { + guard let message = PushMessageParser.parseSilent(userInfo: userInfo) else { + return false + } + statusReporter.reportStatus( + .delivered, + pushId: message.pushId, + siteToken: siteToken, + completion: completion + ) + return true + } + + @discardableResult + package func handleWillPresent( + _ notification: UNNotification, + completion: @escaping (Result) -> Void + ) -> Bool { + switch handle( + status: .delivered, + content: notification.request.content, + completion: completion + ) { + case .handled: + return true + case .notHandled: + return false + } + } + + @discardableResult + package func handleDidReceive( + _ response: UNNotificationResponse, + completion: @escaping (Result) -> Void + ) -> PushNotificationHandlingResult { + handle( + status: response.actionIdentifier == UNNotificationDismissActionIdentifier ? .swiped : .clicked, + actionIdentifier: response.actionIdentifier, + content: response.notification.request.content, + completion: completion + ) + } + + package func actionURLString(in message: PushMessage, for actionIdentifier: String) -> String? { + if actionIdentifier == UNNotificationDefaultActionIdentifier { + return message.clickAction?.stringUrl + } + return button(in: message, for: actionIdentifier)?.value + } + + private func handle( + status: PushNotificationStatus, + actionIdentifier: String? = nil, + content: UNNotificationContent, + completion: @escaping (Result) -> Void + ) -> PushNotificationHandlingResult { + guard let message = PushMessageParser.parse( + userInfo: content.userInfo, + title: content.title, + body: content.body + ) else { + return .notHandled + } + + if status == .delivered, + content.userInfo[PushNotificationMetadata.deliveredByExtensionUserInfoKey] as? Bool == true + { + completion(.success(())) + return .handled(message) + } + + statusReporter.reportStatus( + status, + pushId: message.pushId, + buttonHash: { + if status == .clicked, + let actionIdentifier, + let button = button(in: message, for: actionIdentifier) + { + return button.hash + } + return nil + }(), + siteToken: siteToken, + completion: completion + ) + return .handled(message) + } + + private func button(in message: PushMessage, for actionIdentifier: String) -> PushButton? { + guard + let indexString = actionIdentifier.components(separatedBy: "_").last, + let index = Int(indexString), + index >= 0 && message.buttons.count > index, + actionIdentifier == Self.actionIdentifier(categoryIdentifier: message.pushId, index: index) + else { return nil } + return message.buttons[index] + } + + private static func actionIdentifier(categoryIdentifier: String, index: Int) -> String { + "\(categoryIdentifier)_\(index)" + } +} + +private extension PushButton { + var hash: String { + SHA256.hash(data: Data(value.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/Sources/PushService/PushRegistrationStorage.swift b/Sources/PushService/PushRegistrationStorage.swift new file mode 100644 index 0000000..b41caaf --- /dev/null +++ b/Sources/PushService/PushRegistrationStorage.swift @@ -0,0 +1,107 @@ +import Foundation +import SQLiteStorage + +/// Registration state is local to the application's container and isolated by site. +package final class PushRegistrationStorage: @unchecked Sendable { + package struct State: Equatable { + package var fingerprint: String? + package var desiredCategories: [String]? + package var confirmedCategories: [String]? + package var systemCategories: [String]? + + package init( + fingerprint: String? = nil, + desiredCategories: [String]? = nil, + confirmedCategories: [String]? = nil, + systemCategories: [String]? = nil + ) { + self.fingerprint = fingerprint + self.desiredCategories = desiredCategories + self.confirmedCategories = confirmedCategories + self.systemCategories = systemCategories + } + } + + private let databaseURL: URL? + private let lock = NSLock() + private var database: SQLiteDatabase? + + package init(databaseURL: URL? = nil) { + self.databaseURL = databaseURL + } + + package func load(site: String) throws -> State { + try connection().withConnection { try load(site: site, connection: $0) } + } + + /// Saves the new list and returns the previous list in one transaction. + @discardableResult + package func setDesiredCategories(_ categories: [String], site: String) throws -> [String] { + try connection().transaction { connection in + let previous = try load(site: site, connection: connection).desiredCategories ?? [] + try connection.execute(""" + INSERT INTO push_registration (site, desired_categories) VALUES (?, ?) + ON CONFLICT(site) DO UPDATE SET desired_categories = excluded.desired_categories; + """, bindings: [.text(site), .blob(try JSONEncoder().encode(categories))]) + return previous + } + } + + package func setSystemCategories(_ categories: [String], site: String) throws { + try connection().withConnection { connection in + try connection.execute(""" + INSERT INTO push_registration (site, system_categories) VALUES (?, ?) + ON CONFLICT(site) DO UPDATE SET system_categories = excluded.system_categories; + """, bindings: [.text(site), .blob(try JSONEncoder().encode(categories))]) + } + } + + package func invalidateFingerprint(site: String) throws { + try connection().withConnection { connection in + try connection.execute("UPDATE push_registration SET fingerprint = NULL WHERE site = ?;", bindings: [.text(site)]) + } + } + + /// The fingerprint and confirmed list always describe the same accepted request. + package func confirm(fingerprint: String, categories: [String], site: String) throws { + try connection().withConnection { connection in + try connection.execute(""" + INSERT INTO push_registration (site, fingerprint, confirmed_categories) VALUES (?, ?, ?) + ON CONFLICT(site) DO UPDATE SET fingerprint = excluded.fingerprint, + confirmed_categories = excluded.confirmed_categories; + """, bindings: [.text(site), .text(fingerprint), .blob(try JSONEncoder().encode(categories))]) + } + } + + private func load(site: String, connection: SQLiteConnection) throws -> State { + guard let row = try connection.query(""" + SELECT fingerprint, desired_categories, confirmed_categories, system_categories + FROM push_registration WHERE site = ?; + """, bindings: [.text(site)]).first else { return State() } + func categories(_ value: SQLiteValue) throws -> [String]? { + guard let data = value.data else { return nil } + return try JSONDecoder().decode([String].self, from: data) + } + return try State(fingerprint: row[0].string, desiredCategories: categories(row[1]), + confirmedCategories: categories(row[2]), systemCategories: categories(row[3])) + } + + private func connection() throws -> SQLiteDatabase { + lock.lock() + defer { lock.unlock() } + if let database { return database } + let url = try databaseURL ?? SQLiteDatabase.applicationSupportURL(fileName: "crm_push_registration.sqlite") + let opened = try SQLiteDatabase(url: url) + try opened.migrate([""" + CREATE TABLE push_registration ( + site TEXT PRIMARY KEY NOT NULL, + fingerprint TEXT, + desired_categories BLOB, + confirmed_categories BLOB, + system_categories BLOB + ); + """]) + database = opened + return opened + } +} diff --git a/Sources/PushService/PushService+Version.swift b/Sources/PushService/PushService+Version.swift new file mode 100644 index 0000000..42de1c1 --- /dev/null +++ b/Sources/PushService/PushService+Version.swift @@ -0,0 +1,6 @@ +import Foundation + +public extension PushService { + /// Semantic version of the push service module bundled with this package. + static let version = "0.1.0" +} diff --git a/Sources/PushService/PushService.swift b/Sources/PushService/PushService.swift new file mode 100644 index 0000000..2665c24 --- /dev/null +++ b/Sources/PushService/PushService.swift @@ -0,0 +1,484 @@ +import CryptoKit +import FirebaseCore +import UIKit +import PushNotificationExtension +import FirebaseInstallations +import FirebaseMessaging +import Foundation +import Core +import UserNotifications + +/// Entry point for apps that manage Firebase/APNs integration themselves. +/// +/// Use `PushService` to register FCM tokens or Firebase installation IDs and to report notification +/// status changes when the host app already owns notification delegates and Firebase setup. +public final class PushService: NSObject { + + /// Called on the main queue when a RetailCRM notification or button is opened. + /// Receives the action string and optional `ca_` attributes, including when no URL exists. + /// The application decides how to navigate; dismissing a notification does not call this handler. + @MainActor public var onNotificationOpen: ((NotificationAction) -> Void)? + + /// Reports each completed backend registration or unregistration attempt on the main actor. + /// Reusing a confirmed registration does not emit a result. + @MainActor public var onPushRegistrationResult: ((Result) -> Void)? + + /// Receives the current retry queue and future push-status retry updates for this site. + /// Callbacks run off the main thread. Assign nil to unsubscribe; extension updates stay in its process. + public var onStatusRetryUpdate: ((PushStatusRetryUpdate) -> Void)? { + get { pushHandler.onStatusRetryUpdate } + set { pushHandler.onStatusRetryUpdate = newValue } + } + + @MainActor private static var systemCategoriesTask: Task? + @MainActor private var categories: [String]? + @MainActor private var registrationTask: Task? + @MainActor private var registrationNeedsUpdate = false + @MainActor private var pendingRegistrationCompletions: [(Result) -> Void] = [] + private let registrationStorageKey: String + private let sessionRuntime: SessionRuntime + private let tokenRegistrator: PushTokenRegistrator + private let pushHandler: PushHandler + private let registrationStorage: PushRegistrationStorage + + @MainActor private var pushToken: PushToken? + @MainActor private var sessionReadyForPushRegistration = false { + didSet { + scheduleAutomaticRegistration() + } + } + + /// Creates a push service connected to the shared SDK session. + package init( + sessionRuntime: SessionRuntime, + tokenRegistrator: PushTokenRegistrator? = nil, + pushHandler: PushHandler, + registrationStorage: PushRegistrationStorage = .init() + ) { + self.sessionRuntime = sessionRuntime + self.tokenRegistrator = tokenRegistrator ?? .init( + clientAPIEndpointURL: sessionRuntime.config.endpoints.clientAPIEndpointURL + ) + self.registrationStorage = registrationStorage + let siteHash = SHA256.hash(data: Data(sessionRuntime.config.siteToken.utf8)) + .map { String(format: "%02x", $0) }.joined() + self.registrationStorageKey = siteHash + self.pushHandler = pushHandler + super.init() + } + + /// Replaces the complete desired subscription. An empty list unsubscribes from all categories. + /// Waits for a valid token and connected customer session; skips an already confirmed request. + @MainActor + public func registerNotificationCategories(_ categories: [String]) async throws -> PushToken { + try await withCheckedThrowingContinuation { continuation in + registerNotificationCategories(categories, completion: { continuation.resume(with: $0) }) + } + } + + /// Sets an FCM token or Firebase installation ID obtained by the application. + /// + /// - Parameters: + /// - token: FCM token or Firebase Installation ID obtained by the application. + @MainActor + public func setToken(_ token: PushToken) { + self.pushToken = token + self.scheduleAutomaticRegistration() + } + + /// Resolves an APNs device token to an FCM token or Firebase installation ID. + @MainActor + public func setDeviceToken(_ token: Data) async throws -> PushToken { + try await withCheckedThrowingContinuation { continuation in + setDeviceToken(token, completion: { continuation.resume(with: $0) }) + } + } +} + +/// Completion-based push service API. +public extension PushService { + + /// Replaces the full category list, updates iOS categories and applies the backend subscription. + /// Completion runs on the main actor after backend acceptance, cached confirmation or failure. + /// A different list replaces pending calls with operationSuperseded before their request starts. + @MainActor + func registerNotificationCategories( + _ categories: [String], + completion: @escaping (Result) -> Void + ) { + let desired = Array(Set(categories)).sorted() + let superseded = self.categories != nil && self.categories != desired ? pendingRegistrationCompletions : [] + let previousDesired: [String] + do { + previousDesired = try registrationStorage.setDesiredCategories(desired, site: registrationStorageKey) + } catch { + completion(.failure(.storage(error.localizedDescription))) + return + } + if self.categories != desired { pendingRegistrationCompletions.removeAll() } + self.categories = desired + let systemUpdate = Self.updateSystemNotificationCategories( + desired, storage: registrationStorage, site: registrationStorageKey, previousDesired: previousDesired + ) + let finish: (Result) -> Void = { result in + Task { @MainActor in + do { + try await systemUpdate.value + completion(result) + } catch { + completion(.failure(.storage(error.localizedDescription))) + } + } + } + pendingRegistrationCompletions.append(finish) + scheduleAutomaticRegistration() + superseded.forEach { $0(.failure(.operationSuperseded)) } + } + + /// Passes an APNs device token to Firebase and obtains an FCM token or Firebase installation ID. + /// + /// - Parameters: + /// - token: APNs device token used to obtain a Firebase push identifier. + /// - completion: Receives the FCM token or Firebase installation ID, or an error. + @MainActor + func setDeviceToken( + _ token: Data, + completion: @escaping (Result) -> Void + ) { + pushToken = nil + scheduleAutomaticRegistration() + getToken(deviceToken: token) { [weak self] result in + switch result { + case .success(let token): + self?.pushToken = token + self?.scheduleAutomaticRegistration() + completion(.success((token))) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Reports `.delivered` for a RetailCRM silent notification received by the app delegate. + /// Returns `true` and calls the completion handler with `.noData` after reporting the status. + /// Returns `false` without calling the handler for other notifications. + /// Calls `completion` with the status reporting result only for handled notifications. + /// + /// - Parameters: + /// - userInfo: Payload of the remote notification. + /// - completionHandler: System callback invoked after handling a RetailCRM notification. + /// - completion: Receives the status reporting result for a handled RetailCRM notification. + func handleSilentNotification( + _ userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void, + completion: @escaping (Result) -> Void = { _ in } + ) -> Bool { + self.pushHandler.handleSilentNotification( + userInfo, + completion: { result in + completionHandler(.noData) + completion(Self.statusReportingResult(result)) + } + ) + } + + /// Reports `.delivered` for a RetailCRM foreground notification. + /// Returns `true` and calls the completion handler with banner, badge, and sound options. + /// Returns `false` without calling the handler for other notifications. + /// Calls `completion` with the status reporting result only for handled notifications. + /// + /// - Parameters: + /// - notification: Notification delivered while the application is in the foreground. + /// - completionHandler: System callback invoked after handling a RetailCRM notification. + /// - completion: Receives the status reporting result for a handled RetailCRM notification. + @discardableResult + func handleWillPresent( + _ notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void, + completion: @escaping (Result) -> Void = { _ in } + ) -> Bool { + self.pushHandler.handleWillPresent( + notification, + completion: { result in + completionHandler([.banner, .badge, .sound]) + completion(Self.statusReportingResult(result)) + } + ) + } + + /// Reports `.clicked` or `.swiped` for a RetailCRM notification response. + /// Calls `onNotificationOpen` for opens and button taps, then completes the handled response. + /// Returns `false` without calling the completion handler for other notifications. + /// Calls `completion` with the status reporting result only for handled notifications. + /// + /// - Parameters: + /// - response: User response to a delivered notification. + /// - completionHandler: System callback invoked after handling a RetailCRM notification. + /// - completion: Receives the status reporting result for a handled RetailCRM notification. + @discardableResult + func handleDidReceive( + _ response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void, + completion: @escaping (Result) -> Void = { _ in } + ) -> Bool { + let group = DispatchGroup() + group.enter() + switch pushHandler.handleDidReceive( + response, + completion: { result in + group.leave() + completion(Self.statusReportingResult(result)) + } + ) { + case .handled(let push): + if response.actionIdentifier != UNNotificationDismissActionIdentifier { + let userInfo = response.notification.request.content.userInfo + if let utm = CrmUtmAttribution( + source: userInfo["utm_source"] as? String, + medium: userInfo["utm_medium"] as? String, + campaign: userInfo["utm_campaign"] as? String, + term: userInfo["utm_term"] as? String, + content: userInfo["utm_content"] as? String + ) { + CrmUtmAttributionStorage.shared.save(utm) + } + let action = NotificationAction( + urlString: pushHandler.actionURLString(in: push, for: response.actionIdentifier), + customAttributes: push.clickAction?.customAttributes + ) + group.enter() + DispatchQueue.main.async { + self.onNotificationOpen?(action) + group.leave() + } + } + group.notify(queue: .main) { + completionHandler() + } + return true + case .notHandled: + group.leave() + return false + } + } +} + +private extension PushService { + + @MainActor + static func updateSystemNotificationCategories( + _ desired: [String], storage: PushRegistrationStorage, site: String, previousDesired: [String] + ) -> Task { + let previous = systemCategoriesTask + let task = Task { @MainActor in + _ = try? await previous?.value + let state = try storage.load(site: site) + let managed = state.systemCategories ?? previousDesired + let confirmed = state.confirmedCategories ?? [] + let desiredIDs = Set(desired) + let ownedIDs = Set(managed).union(confirmed).union(desiredIDs) + await withCheckedContinuation { (continuation: CheckedContinuation) in + UNUserNotificationCenter.current().getNotificationCategories { registered in + var updated = registered.filter { !ownedIDs.contains($0.identifier) } + for identifier in desiredIDs { + updated.insert(UNNotificationCategory( + identifier: identifier, actions: [], intentIdentifiers: [], options: [.customDismissAction] + )) + } + UNUserNotificationCenter.current().setNotificationCategories(updated) + continuation.resume() + } + } + try storage.setSystemCategories(desired, site: site) + } + systemCategoriesTask = task + return task + } + + static func statusReportingResult( + _ result: Result + ) -> Result { + result.mapError { error in + let failure: PushServiceError.StatusReportingFailure + switch error { + case let .requestEncodingFailed(message): + failure = .requestEncodingFailed(message) + case let .invalidHTTPResponse(description): + failure = .invalidHTTPResponse(description) + case let .requestFailed(message): + failure = .requestFailed(message) + case let .httpStatus(statusCode, messages): + failure = .httpStatus(statusCode, messages) + } + return .statusReporting(failure) + } + } + + @MainActor + func scheduleAutomaticRegistration() { + registrationNeedsUpdate = true + guard registrationTask == nil else { return } + registrationTask = Task { @MainActor in + defer { registrationTask = nil } + while registrationNeedsUpdate { + registrationNeedsUpdate = false + let identity = await sessionRuntime.customerIdentity + // No list means the integrator has not configured this launch yet; [] means unsubscribe. + guard sessionReadyForPushRegistration, + let pushToken, (try? pushToken.normalizedToken()) != nil, + let identity, let savedCategories = categories else { return } + let completions = pendingRegistrationCompletions + pendingRegistrationCompletions.removeAll() + var result: Result + do { + let validatedIdentity: CrmSdkCustomerIdentity + do { + validatedIdentity = try identity.validated() + } catch { + throw PushServiceError.invalidCustomerIdentity(error.localizedDescription) + } + let token = try pushToken.normalizedToken() + let projectID = try resolvedFirebaseProjectId() + let categories = Array(Set(savedCategories)).sorted() + let body = PushTokenRequestBody( + customerId: validatedIdentity.customerId, + customerExternalId: validatedIdentity.siteCustomerId, + crmSiteCode: validatedIdentity.siteCode, + token: token.fcmValue, fid: token.fidValue, + firebaseProjectId: projectID, categories: categories + ) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + var registrationData: Data + do { + registrationData = try encoder.encode(body) + } catch { + throw PushServiceError.requestEncodingFailed(error.localizedDescription) + } + registrationData.append(Data(sessionRuntime.config.endpoints.clientAPIEndpointURL.absoluteString.utf8)) + let fingerprint = SHA256.hash(data: registrationData) + .map { String(format: "%02x", $0) }.joined() + if try registrationStorage.load(site: registrationStorageKey).fingerprint == fingerprint { + completions.forEach { $0(.success(token)) } + continue + } + // A failed/interrupted request may have reached the backend; invalidate the old state. + try registrationStorage.invalidateFingerprint(site: registrationStorageKey) + result = await withCheckedContinuation { continuation in + tokenRegistrator.registerToken( + token, customerIdentity: validatedIdentity, siteToken: sessionRuntime.config.siteToken, + firebaseProjectId: projectID, categories: categories + ) { continuation.resume(returning: $0) } + } + if case .success = result { + try registrationStorage.confirm(fingerprint: fingerprint, categories: categories, site: registrationStorageKey) + } + } catch { + if let error = error as? PushServiceError { + result = .failure(error) + } else { + result = .failure(.storage(error.localizedDescription)) + } + } + onPushRegistrationResult?(result) + completions.forEach { $0(result) } + } + } + } + + @MainActor + func getToken(deviceToken: Data, completion: @escaping (Result) -> Void) { + Messaging.messaging().apnsToken = deviceToken + + if Messaging.messaging().isInstallationIdEnabled { + messagingRegisterIfPossible { [weak self] result in + switch result { + case .success: + self?.getFidToken { result in + completion(result.map(PushToken.fid)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } else { + getFcmToken { result in + completion(result.map(PushToken.fcm)) + } + } + } + + func messagingRegisterIfPossible(completion: @escaping (Result) -> Void) { + guard Messaging.messaging().isInstallationIdEnabled else { + completion(.failure(.firebaseInstallationIDDisabled)) + return + } + + Messaging.messaging().register { error in + if let error { + completion(.failure(.firebaseMessaging(error))) + return + } + completion(.success(())) + } + } + + func getFcmToken(completion: @escaping (Result) -> Void) { + Messaging.messaging().token { token, error in + if let error { + completion(.failure(.firebaseMessaging(error))) + return + } + guard let token else { + completion(.failure(.emptyFCMToken)) + return + } + completion(.success(token)) + } + } + + func getFidToken(completion: @escaping (Result) -> Void) { + Installations.installations().installationID { token, error in + if let error { + completion(.failure(.firebaseInstallations(error))) + return + } + guard let token else { + completion(.failure(.emptyFID)) + return + } + completion(.success(token)) + } + } + + func resolvedFirebaseProjectId() throws -> String { + if let firebaseProjectId = FirebaseApp.app()?.options.projectID?.nilIfEmpty { + return firebaseProjectId + } + throw PushServiceError.emptyFirebaseProjectID + } +} + +extension PushService: SessionLifecycleListener { + package func sessionDidConnect( + credentials: SessionCredentials, + pageContext: ConnectPageContext, + user: ConnectUser, + response _: ConnectResponse, + customerIdentity _: CrmSdkCustomerIdentity? + ) { + Task { @MainActor [weak self] in + guard let _ = await self?.sessionRuntime.customerIdentity else { + self?.sessionReadyForPushRegistration = false + return + } + self?.sessionReadyForPushRegistration = true + } + } + + package func sessionDidDisconnect() { + Task { @MainActor [weak self] in + self?.sessionReadyForPushRegistration = false + } + } +} diff --git a/Sources/PushService/PushTokenRegistrator.swift b/Sources/PushService/PushTokenRegistrator.swift new file mode 100644 index 0000000..1852679 --- /dev/null +++ b/Sources/PushService/PushTokenRegistrator.swift @@ -0,0 +1,88 @@ +import Foundation +import Core + +/// Registers push identifiers in the client API. +/// +/// `PushTokenRegistrator` is a package-scoped transport component used by `PushService`. It sends FCM +/// tokens or Firebase installation IDs to `/push-tokens` with the configured site token. +package struct PushTokenRegistrator: Sendable { + + private let session: URLSession + private let clientAPIEndpointURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Creates a registrator with a URL session configured from request timeouts. + package init( + session: URLSession = NetworkSession.shared, + clientAPIEndpointURL: URL = APIEndpoints().clientAPIEndpointURL + ) { + self.clientAPIEndpointURL = clientAPIEndpointURL + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + self.session = session + } + + /// Registers one push identifier for the provided customer and Firebase project. + package func registerToken( + _ token: PushToken, + customerIdentity: CrmSdkCustomerIdentity, + siteToken: String, + firebaseProjectId: String, + categories: [String], + completion: @escaping (Result) -> Void + ) { + let requestBody = PushTokenRequestBody( + customerId: customerIdentity.customerId, + customerExternalId: customerIdentity.siteCustomerId, + crmSiteCode: customerIdentity.siteCode, + token: token.fcmValue, + fid: token.fidValue, + firebaseProjectId: firebaseProjectId, + categories: categories + ) + + var request = URLRequest( + url: clientAPIEndpointURL.appendingPathComponent("push-tokens") + ) + request.httpMethod = "POST" + do { + request.httpBody = try encoder.encode(requestBody) + } catch { + completion(.failure(.requestEncodingFailed(error.localizedDescription))) + return + } + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(siteToken, forHTTPHeaderField: "X-Site-Token") + + let task = NetworkSession.dataTask( + with: request, in: session + ) { [decoder] data, response, error in + if let error { + completion(.failure(.requestFailed(error.localizedDescription))) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(.invalidHTTPResponse(String(describing: response)))) + return + } + + guard httpResponse.statusCode == 204 else { + let messages = HTTPErrorResponseParser.messages( + from: data ?? Data(), fallbackForEmptyMessages: true + ) { (try? decoder.decode(PushServiceErrorResponse.self, from: $0))?.errors } + completion(.failure(.httpStatus(httpResponse.statusCode, messages))) + return + } + + completion(.success(token)) + } + task.resume() + } +} + +private struct PushServiceErrorResponse: Decodable { + let errors: [String] +} diff --git a/Sources/PushService/Resources/en.lproj/Localizable.strings b/Sources/PushService/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..0489057 --- /dev/null +++ b/Sources/PushService/Resources/en.lproj/Localizable.strings @@ -0,0 +1,18 @@ +"push.error.authorization_denied" = "Notification permission was denied."; +"push.error.empty_push_token" = "Push token is not ready yet."; +"push.error.empty_fcm_token" = "FCM registration token is empty."; +"push.error.empty_fid" = "Firebase installation id is empty."; +"push.error.empty_firebase_project_id" = "Firebase project id is empty."; +"push.error.firebase_installation_id_disabled" = "Firebase installation id mode is disabled."; +"push.error.missing_customer_identifier" = "Customer id or customer external id is required."; +"push.error.invalid_http_response" = "Push token registration response is not a valid HTTP response: %@"; +"push.error.request_encoding_failed" = "Push token registration request could not be encoded: %@"; +"push.error.request_failed" = "Push token registration request failed: %@"; +"push.error.http_status" = "Push token registration failed with HTTP status %d."; +"push.error.http_status_with_details" = "Push token registration failed with HTTP status %d: %@"; +"push.error.status_reporting_invalid_http_response" = "Push status response is not a valid HTTP response: %@"; +"push.error.status_reporting_request_encoding_failed" = "Push status request could not be encoded: %@"; +"push.error.status_reporting_request_failed" = "Push status request failed: %@"; +"push.error.status_reporting_http_status" = "Push status request failed with HTTP status %d."; +"push.error.status_reporting_http_status_with_details" = "Push status request failed with HTTP status %d: %@"; +"push.error.operation_superseded" = "A newer notification category list replaced this pending operation."; diff --git a/Sources/PushService/Resources/es.lproj/Localizable.strings b/Sources/PushService/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..563876d --- /dev/null +++ b/Sources/PushService/Resources/es.lproj/Localizable.strings @@ -0,0 +1,18 @@ +"push.error.authorization_denied" = "Se denegó el permiso para mostrar notificaciones."; +"push.error.empty_push_token" = "El token push aún no está listo."; +"push.error.empty_fcm_token" = "El token de registro FCM está vacío."; +"push.error.empty_fid" = "El Firebase installation id está vacío."; +"push.error.empty_firebase_project_id" = "El Firebase project id está vacío."; +"push.error.firebase_installation_id_disabled" = "El modo Firebase installation id está desactivado."; +"push.error.missing_customer_identifier" = "Se requiere customer id o customer external id."; +"push.error.invalid_http_response" = "La respuesta de registro del token push no es una respuesta HTTP válida: %@"; +"push.error.request_encoding_failed" = "No se pudo codificar la solicitud de registro del token push: %@"; +"push.error.request_failed" = "La solicitud de registro del token push falló: %@"; +"push.error.http_status" = "El registro del token push falló con estado HTTP %d."; +"push.error.http_status_with_details" = "El registro del token push falló con estado HTTP %d: %@"; +"push.error.status_reporting_invalid_http_response" = "La respuesta del estado push no es una respuesta HTTP válida: %@"; +"push.error.status_reporting_request_encoding_failed" = "No se pudo codificar la solicitud del estado push: %@"; +"push.error.status_reporting_request_failed" = "La solicitud del estado push falló: %@"; +"push.error.status_reporting_http_status" = "La solicitud del estado push falló con el estado HTTP %d."; +"push.error.status_reporting_http_status_with_details" = "La solicitud del estado push falló con el estado HTTP %d: %@"; +"push.error.operation_superseded" = "Una nueva lista de categorías de notificación sustituyó esta operación pendiente."; diff --git a/Sources/PushService/Resources/ru.lproj/Localizable.strings b/Sources/PushService/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000..1b0d6b0 --- /dev/null +++ b/Sources/PushService/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,18 @@ +"push.error.authorization_denied" = "Пользователь запретил показ уведомлений."; +"push.error.empty_push_token" = "Push-токен ещё не готов."; +"push.error.empty_fcm_token" = "FCM registration token пуст."; +"push.error.empty_fid" = "Firebase installation id пуст."; +"push.error.empty_firebase_project_id" = "Firebase project id пуст."; +"push.error.firebase_installation_id_disabled" = "Режим Firebase installation id отключен."; +"push.error.missing_customer_identifier" = "Требуется customer id или customer external id."; +"push.error.invalid_http_response" = "Ответ регистрации push-токена не является корректным HTTP-ответом: %@"; +"push.error.request_encoding_failed" = "Не удалось закодировать запрос регистрации push-токена: %@"; +"push.error.request_failed" = "Запрос регистрации push-токена завершился ошибкой: %@"; +"push.error.http_status" = "Регистрация push-токена завершилась HTTP-статусом %d."; +"push.error.http_status_with_details" = "Регистрация push-токена завершилась HTTP-статусом %d: %@"; +"push.error.status_reporting_invalid_http_response" = "Ответ отправки статуса push-уведомления не является корректным HTTP-ответом: %@"; +"push.error.status_reporting_request_encoding_failed" = "Не удалось закодировать запрос отправки статуса push-уведомления: %@"; +"push.error.status_reporting_request_failed" = "Отправка статуса push-уведомления завершилась ошибкой: %@"; +"push.error.status_reporting_http_status" = "Отправка статуса push-уведомления завершилась с HTTP-статусом %d."; +"push.error.status_reporting_http_status_with_details" = "Отправка статуса push-уведомления завершилась с HTTP-статусом %d: %@"; +"push.error.operation_superseded" = "Ожидающая операция заменена новым списком категорий уведомлений."; diff --git a/Sources/PushService/SDKResourceBundle.swift b/Sources/PushService/SDKResourceBundle.swift new file mode 100644 index 0000000..991f749 --- /dev/null +++ b/Sources/PushService/SDKResourceBundle.swift @@ -0,0 +1,18 @@ +#if !SWIFT_PACKAGE +import Foundation + +private final class SDKResourceBundleToken {} + +extension Bundle { + static let module: Bundle = { + let moduleBundle = Bundle(for: SDKResourceBundleToken.self) + for container in [moduleBundle, Bundle.main] { + if let url = container.url(forResource: "SimlaPushService", withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } + } + fatalError("Missing SimlaPushService.bundle") + }() +} +#endif diff --git a/Sources/Recommendations/CrmSdk+Recommendations.swift b/Sources/Recommendations/CrmSdk+Recommendations.swift new file mode 100644 index 0000000..b4afbac --- /dev/null +++ b/Sources/Recommendations/CrmSdk+Recommendations.swift @@ -0,0 +1,16 @@ +import Core +import Tracker + +public extension CrmSdk { + + /// Shared recommendations module configured during SDK initialization. + var recommendations: Recommendations { + module(Recommendations.self) { + let tracker = self.tracker + return Recommendations( + sessionRuntime: sessionRuntime, + tracker: tracker + ) + } + } +} diff --git a/Sources/Recommendations/Models/ProductRecommendations.swift b/Sources/Recommendations/Models/ProductRecommendations.swift new file mode 100644 index 0000000..895c06e --- /dev/null +++ b/Sources/Recommendations/Models/ProductRecommendations.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Product recommendations returned by the backend. +public struct ProductRecommendations: Decodable, Equatable, Sendable { + + /// Core recommendation mechanic execution identifier used later for attribution events. + public let executionID: UInt64 + + /// Whether Core returned a personalized recommendation selection. + public let personalized: Bool + + /// Core recommendation execution context. + public let context: ProductRecommendationContext + + /// Recommended product ids that have available offers. + public let productIds: [Int64] + + /// Recommended available product offers. + public let products: [RecommendedProduct] + + private enum CodingKeys: String, CodingKey { + case executionID = "executionId" + case personalized + case context + case productIds + case products + } + + /// Creates a product recommendation selection. + /// + /// - Parameters: + /// - executionID: Identifier of the executed recommendation mechanic, used for attribution. + /// - personalized: Whether the recommendation selection is personalized. + /// - context: Mechanic execution context returned with the selection. + /// - productIds: IDs of recommended products with available offers. + /// - products: Available recommended product offers. + public init( + executionID: UInt64, + personalized: Bool, + context: ProductRecommendationContext, + productIds: [Int64] = [], + products: [RecommendedProduct] = [] + ) { + self.executionID = executionID + self.personalized = personalized + self.context = context + self.productIds = productIds + self.products = products + } + + /// Decodes a `ProductRecommendations` from the recommendations response. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + executionID = try container.decode(UInt64.self, forKey: .executionID) + personalized = try container.decode(Bool.self, forKey: .personalized) + context = try container.decode(ProductRecommendationContext.self, forKey: .context) + productIds = try container.decodeIfPresent([Int64].self, forKey: .productIds) ?? [] + products = try container.decodeIfPresent([RecommendedProduct].self, forKey: .products) ?? [] + } +} + +/// Core recommendation execution context returned with a product selection. +public struct ProductRecommendationContext: Decodable, Equatable, Sendable { + + /// Core mechanic type, for example `personal` or `similar`. + public let mechanicType: String + + /// Context fields required by the executed mechanic. + public let required: [String] + + /// Creates a recommendation context value. + /// + /// - Parameters: + /// - mechanicType: Type of executed recommendation mechanic. + /// - required: Context fields required by the recommendation mechanic. + public init( + mechanicType: String, + required: [String] = [] + ) { + self.mechanicType = mechanicType + self.required = required + } + + /// Decodes a `ProductRecommendationContext` value from its serialized representation. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.mechanicType = try container.decode(String.self, forKey: .mechanicType) + self.required = try container.decodeIfPresent([String].self, forKey: .required) ?? [] + } + + private enum CodingKeys: String, CodingKey { + case mechanicType + case required + } +} diff --git a/Sources/Recommendations/Models/ProductRecommendationsRequest.swift b/Sources/Recommendations/Models/ProductRecommendationsRequest.swift new file mode 100644 index 0000000..071f0d0 --- /dev/null +++ b/Sources/Recommendations/Models/ProductRecommendationsRequest.swift @@ -0,0 +1,136 @@ +import Core +import Foundation + +/// Product recommendation parameters. The SDK supplies the customer from the shared session. +public struct ProductRecommendationsRequest: Encodable, Equatable, Sendable { + + /// Symbolic Core mechanic code. + public let mechanicCode: String + + /// retailCRM product id used by product-based recommendations. + public let productID: Int64? + + /// retailCRM product external id used by product-based recommendations. + public let productExternalID: String? + + /// retailCRM offer id used by product-based recommendations. + public let offerID: Int64? + + /// retailCRM offer external id used by product-based recommendations. + public let offerExternalID: String? + + /// Recommendation block code. + public let blockCode: String + + /// Creates a product recommendations request. + /// + /// - Parameters: + /// - mechanicCode: Nonempty mechanic code. + /// - blockCode: Nonempty code of the recommendation placement. + /// - productID: Internal product ID used as recommendation context. + /// - productExternalID: External product ID used as recommendation context. + /// - offerID: Internal offer ID used as recommendation context. + /// - offerExternalID: External offer ID used as recommendation context. + public init( + mechanicCode: String, + blockCode: String, + productID: Int64? = nil, + productExternalID: String? = nil, + offerID: Int64? = nil, + offerExternalID: String? = nil + ) { + self.mechanicCode = mechanicCode + self.blockCode = blockCode.trimmingCharacters(in: .whitespacesAndNewlines) + self.productID = productID + self.productExternalID = productExternalID?.nilIfEmpty + self.offerID = offerID + self.offerExternalID = offerExternalID?.nilIfEmpty + } + + private enum CodingKeys: String, CodingKey { + case mechanicCode = "mechanic_code" + case productID = "product_id" + case productExternalID = "product_external_id" + case offerID = "offer_id" + case offerExternalID = "offer_external_id" + case blockCode = "block_code" + } +} + +package extension ProductRecommendationsRequest { + + func validate() throws { + guard !mechanicCode.isEmpty else { + throw RecommendationsError.invalidRequest( + RecommendationsLocalization.mechanicCodeEmpty.localized + ) + } + + guard !blockCode.isEmpty else { + throw RecommendationsError.invalidRequest( + RecommendationsLocalization.blockCodeEmpty.localized + ) + } + + if let productID, productID <= 0 { + throw RecommendationsError.invalidRequest( + RecommendationsLocalization.productIDInvalid.localized + ) + } + + if let offerID, offerID <= 0 { + throw RecommendationsError.invalidRequest( + RecommendationsLocalization.offerIDInvalid.localized + ) + } + + guard productID == nil || productExternalID == nil else { + throw RecommendationsError.invalidRequest( + RecommendationsLocalization.productContextConflict.localized + ) + } + + guard offerID == nil || offerExternalID == nil else { + throw RecommendationsError.invalidRequest( + RecommendationsLocalization.offerContextConflict.localized + ) + } + + let hasProductContext = productID != nil || productExternalID != nil + let hasOfferContext = offerID != nil || offerExternalID != nil + guard !hasProductContext || !hasOfferContext else { + throw RecommendationsError.invalidRequest( + RecommendationsLocalization.productAndOfferConflict.localized + ) + } + } +} + +/// Adds the current session's customer identity to the backend request. +package struct ProductRecommendationsRequestBody: Encodable { + package let request: ProductRecommendationsRequest + package let customerIdentity: CrmSdkCustomerIdentity? + + private enum CodingKeys: String, CodingKey { + case systemCustomerID = "system_customer_id" + case systemCustomerExternalID = "system_customer_external_id" + } + + package func encode(to encoder: Encoder) throws { + try request.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + if let customerID = customerIdentity?.customerId { + try container.encode(customerID, forKey: .systemCustomerID) + } else { + try container.encodeIfPresent( + customerIdentity?.siteCustomerId?.nilIfEmpty, + forKey: .systemCustomerExternalID + ) + } + } + + package init(request: ProductRecommendationsRequest, customerIdentity: CrmSdkCustomerIdentity?) { + self.request = request + self.customerIdentity = customerIdentity + } +} diff --git a/Sources/Recommendations/Models/RecommendationsError.swift b/Sources/Recommendations/Models/RecommendationsError.swift new file mode 100644 index 0000000..e11ea0e --- /dev/null +++ b/Sources/Recommendations/Models/RecommendationsError.swift @@ -0,0 +1,73 @@ +import Core +import Foundation +import Tracker + +/// Errors returned by product recommendation operations. +public enum RecommendationsError: Error, Equatable, LocalizedError, Sendable { + case invalidRequest(String) + case missingConnection + case httpStatus(Int, [String]) + case invalidResponse(String) + case requestFailed(String) + case trackingUnavailable + case consentRequired + case trackingFailed(String) + case cancelled + + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): return message + case .missingConnection: return RecommendationsLocalization.missingConnection.localized + case .httpStatus(let code, let messages): + let details = messages.joined(separator: " ") + return details.isEmpty + ? RecommendationsLocalization.httpStatus.format(code) + : RecommendationsLocalization.httpStatusWithDetails.format(code, details) + case .invalidResponse(let message): + return RecommendationsLocalization.invalidResponse.format(message) + case .requestFailed(let message): + return RecommendationsLocalization.requestFailed.format(message) + case .trackingUnavailable: + return RecommendationsLocalization.trackingUnavailable.localized + case .consentRequired: + return RecommendationsLocalization.consentRequired.localized + case .trackingFailed(let message): + return RecommendationsLocalization.trackingFailed.format(message) + case .cancelled: + return RecommendationsLocalization.cancelled.localized + } + } +} + +package extension RecommendationsError { + static func from(_ error: Error) -> Self { + if let error = error as? Self { return error } + if let error = error as? TrackerError { return from(error) } + if error is CancellationError { return .cancelled } + if let error = error as? URLError, error.code == .cancelled { return .cancelled } + if let error = error as? CoreError { + switch error { + case .missingConnectionCredentials: return .missingConnection + case .httpStatus(let code, let messages): return .httpStatus(code, messages) + case .responseDecodingFailed(let message): return .invalidResponse(message) + case .cancelled: return .cancelled + default: return .requestFailed(error.localizedDescription) + } + } + if error is DecodingError { return .invalidResponse(error.localizedDescription) } + return .requestFailed(error.localizedDescription) + } + + static func from(_ error: TrackerError) -> Self { + switch error { + case .invalidEvent(let message): return .invalidRequest(message) + case .consentRequired: return .consentRequired + case .httpStatus(let status, let messages, _): return .httpStatus(status, messages) + case .invalidResponse(let message): return .invalidResponse(message) + case .requestFailed(let message): return .requestFailed(message) + case .missingConnection: return .missingConnection + case .unavailable: return .trackingUnavailable + default: return .trackingFailed(error.localizedDescription) + } + } +} diff --git a/Sources/Recommendations/Models/RecommendedProduct.swift b/Sources/Recommendations/Models/RecommendedProduct.swift new file mode 100644 index 0000000..12975ac --- /dev/null +++ b/Sources/Recommendations/Models/RecommendedProduct.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Available product offer returned in a recommendation selection. +public struct RecommendedProduct: Decodable, Equatable, Sendable { + + /// retailCRM offer id. + public let id: Int64 + + /// retailCRM product id. + public let productID: Int64 + + /// Offer name, or product name when offer name is empty. + public let name: String + + /// Current offer price. + public let price: Double + + /// Previous offer price. + public let oldPrice: Double + + /// Price currency. + public let currency: String + + /// Offer image URL, or product image URL when offer image is empty. + public let imageUrl: String + + /// Product URL. + public let url: String + + /// Availability flag returned by the backend. + public let available: Bool + + private enum CodingKeys: String, CodingKey { + case id + case productID = "productId" + case name, price, oldPrice, currency, imageUrl, url, available + } + + /// Decodes a `RecommendedProduct` from the recommendations response. + /// + /// - Parameters: + /// - decoder: Decoder containing the encoded value. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int64.self, forKey: .id) + productID = try container.decode(Int64.self, forKey: .productID) + name = try container.decode(String.self, forKey: .name) + price = try container.decode(Double.self, forKey: .price) + oldPrice = try container.decodeIfPresent(Double.self, forKey: .oldPrice) ?? 0 + currency = try container.decode(String.self, forKey: .currency) + imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) ?? "" + url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + available = try container.decodeIfPresent(Bool.self, forKey: .available) ?? true + } +} diff --git a/Sources/Recommendations/Recommendations.swift b/Sources/Recommendations/Recommendations.swift new file mode 100644 index 0000000..d55ef90 --- /dev/null +++ b/Sources/Recommendations/Recommendations.swift @@ -0,0 +1,257 @@ +import Core +import Foundation +import Tracker + +/// Loads product recommendations and records interactions through the shared tracker. +public final class Recommendations { + private let sessionRuntime: SessionRuntime + private let tracker: Tracker + private let session: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + /// Forwards shared tracker preparation updates on the main thread. + /// Assigning this handler replaces Tracker.onReadyUpdate; nil clears the shared handler. + public var onReadyUpdate: ((Bool, TrackerError?) -> Void)? { + get { tracker.onReadyUpdate } + set { tracker.onReadyUpdate = newValue } + } + + /// Current tracking consent. The application persists and restores this decision. + public var consentState: TrackerConsentState { tracker.consentState } + + /// Forwards tracker queue and delivery updates to the assigned handler. + /// Assigning this handler replaces `Tracker.onEventUpdate` because both use the shared tracker. + @MainActor public var onEventUpdate: ((TrackerEventUpdate) -> Void)? { + get { tracker.onEventUpdate } + set { tracker.onEventUpdate = newValue } + } + + package convenience init(sessionRuntime: SessionRuntime, tracker: Tracker) { + self.init( + sessionRuntime: sessionRuntime, + tracker: tracker, + session: NetworkSession.shared + ) + } + + package init(sessionRuntime: SessionRuntime, tracker: Tracker, session: URLSession) { + self.sessionRuntime = sessionRuntime + self.tracker = tracker + self.session = session + } + + /// Applies the user's tracking decision to the shared tracker. + /// + /// - Parameters: + /// - consent: User tracking decision shared with the Tracker module. + @discardableResult + public func setConsent(_ consent: RecommendationsConsentState) -> Result { + let trackerConsent: TrackerConsentState = { + switch consent { + case .unknown: .unknown + case .granted: .granted + case .denied: .denied + } + }() + return tracker.setConsent(trackerConsent).mapError(RecommendationsError.from) + } + + /// Loads a product recommendation selection. An empty selection is a successful result. + /// + /// Uses the customer identity from the shared SDK session. + /// + /// - Parameters: + /// - requestBody: Recommendation mechanic and optional product or offer context. + public func getProducts(_ requestBody: ProductRecommendationsRequest) async throws -> ProductRecommendations { + do { + try requestBody.validate() + let context = try await sessionRuntime.authorizedRequestContext() + var request = URLRequest(url: endpointURL("recommendations/products")) + request.httpMethod = "POST" + context.headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + request.httpBody = try encoder.encode( + ProductRecommendationsRequestBody(request: requestBody, customerIdentity: context.customerIdentity) + ) + + let (data, response) = try await NetworkSession.data( + for: request, + in: session + ) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(statusCode) else { + let messages = (try? decoder.decode(ErrorResponse.self, from: data).errors) ?? [] + throw RecommendationsError.httpStatus(statusCode, messages) + } + + return try decoder.decode(ProductRecommendations.self, from: data) + } catch { + throw RecommendationsError.from(error) + } + } + + /// Records a visible recommendation block and returns its queued tracker event ID. + /// + /// - Parameters: + /// - recommendation: Recommendation selection whose execution ID identifies the event. + /// - screenURI: Absolute URI of the screen where the event occurred. + /// - title: Optional title of the screen showing the recommendations. + public func trackImpression( + _ recommendation: ProductRecommendations, + screenURI: String, + title: String? = nil + ) async throws -> String { + do { + try Task.checkCancellation() + guard recommendation.executionID > 0 else { + throw RecommendationsError.invalidRequest(RecommendationsLocalization.executionIDInvalid.localized) + } + let uri = screenURI.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: uri), url.scheme != nil else { + throw RecommendationsError.invalidRequest(RecommendationsLocalization.screenURIInvalid.localized) + } + let result = await withCheckedContinuation { continuation in + tracker.trackRecommendationImpression( + executionID: recommendation.executionID, + screenURI: uri, + title: title, + completion: { continuation.resume(returning: $0) } + ) + } + return try result.get() + } catch let error as TrackerError { + throw RecommendationsError.from(error) + } catch { + throw RecommendationsError.from(error) + } + } + + /// Records a product click and returns its queued tracker event ID. + /// + /// - Parameters: + /// - recommendation: Recommendation selection whose execution ID identifies the event. + /// - productID: Internal ID of the clicked recommended product. + /// - position: Optional zero-based position of the clicked product. + /// - element: Optional identifier of the clicked UI element. + /// - leaving: Whether selecting the product leaves the current screen. + public func trackClick( + _ recommendation: ProductRecommendations, + productID: Int64, + position: Int? = nil, + element: String? = nil, + leaving: Bool = false + ) async throws -> String { + do { + try Task.checkCancellation() + guard recommendation.executionID > 0 else { + throw RecommendationsError.invalidRequest(RecommendationsLocalization.executionIDInvalid.localized) + } + guard productID > 0, productID <= Int64(UInt32.max) else { + throw RecommendationsError.invalidRequest(RecommendationsLocalization.clickProductIDInvalid.localized) + } + if let position, !(0...Int(UInt32.max)).contains(position) { + throw RecommendationsError.invalidRequest(RecommendationsLocalization.positionInvalid.localized) + } + let result = await withCheckedContinuation { continuation in + tracker.trackRecommendationClick( + executionID: recommendation.executionID, + productID: productID, + position: position, + element: element, + leaving: leaving, + completion: { continuation.resume(returning: $0) } + ) + } + return try result.get() + } catch let error as TrackerError { + throw RecommendationsError.from(error) + } catch { + throw RecommendationsError.from(error) + } + } + + private func endpointURL(_ path: String) -> URL { + path.split(separator: "/").reduce(sessionRuntime.config.endpoints.clientAPIEndpointURL) { + $0.appendingPathComponent(String($1)) + } + } + + private struct ErrorResponse: Decodable { + let errors: [String] + } +} + +/// Completion-based API for callers that do not use Swift concurrency. +public extension Recommendations { + + /// Loads a product recommendation selection and reports the result on the main thread. + /// + /// - Parameters: + /// - requestBody: Recommendation mechanic and optional customer or product context. + /// - completion: Receives the selection or a recommendation error on the main thread. + func getProducts( + _ requestBody: ProductRecommendationsRequest, + completion: @escaping (Result) -> Void + ) { + Task { + let result: Result + do { result = .success(try await getProducts(requestBody)) } + catch { result = .failure(RecommendationsError.from(error)) } + await MainActor.run { completion(result) } + } + } + + /// Records a displayed recommendation block and reports its queued tracker event ID. + /// The completion runs on the main thread. + /// + /// - Parameters: + /// - recommendation: Recommendation selection whose execution ID identifies the event. + /// - screenURI: Absolute URI of the screen where the event occurred. + /// - title: Optional title of the screen showing the recommendations. + /// - completion: Receives the queued event ID or an error on the main thread. + func trackImpression( + _ recommendation: ProductRecommendations, + screenURI: String, + title: String? = nil, + completion: @escaping (Result) -> Void + ) { + Task { + let result: Result + do { result = .success(try await trackImpression(recommendation, screenURI: screenURI, title: title)) } + catch { result = .failure(RecommendationsError.from(error)) } + await MainActor.run { completion(result) } + } + } + + /// Records a click on a recommended product and reports its queued tracker event ID. + /// The completion runs on the main thread. + /// + /// - Parameters: + /// - recommendation: Recommendation selection whose execution ID identifies the event. + /// - productID: Internal ID of the clicked recommended product. + /// - position: Optional zero-based position of the clicked product. + /// - element: Optional identifier of the clicked UI element. + /// - leaving: Whether selecting the product leaves the current screen. + /// - completion: Receives the queued event ID or an error on the main thread. + func trackClick( + _ recommendation: ProductRecommendations, + productID: Int64, + position: Int? = nil, + element: String? = nil, + leaving: Bool = false, + completion: @escaping (Result) -> Void + ) { + Task { + let result: Result + do { + result = .success(try await trackClick( + recommendation, productID: productID, position: position, + element: element, leaving: leaving + )) + } catch { + result = .failure(RecommendationsError.from(error)) + } + await MainActor.run { completion(result) } + } + } +} diff --git a/Sources/Recommendations/RecommendationsConsentState.swift b/Sources/Recommendations/RecommendationsConsentState.swift new file mode 100644 index 0000000..ee7159c --- /dev/null +++ b/Sources/Recommendations/RecommendationsConsentState.swift @@ -0,0 +1,19 @@ +// +// RecommendationsConsentState.swift +// ConsultantSdk +// +// Created by Yriy Deviataev on 16.10.2026. +// + +/// User consent state for tracker events. +public enum RecommendationsConsentState: Equatable, Sendable { + + /// Consent has not been explicitly granted or denied. + case unknown + + /// User granted permission to send tracker events. + case granted + + /// User denied permission to send tracker events. + case denied +} diff --git a/Sources/Recommendations/RecommendationsLocalization.swift b/Sources/Recommendations/RecommendationsLocalization.swift new file mode 100644 index 0000000..f289244 --- /dev/null +++ b/Sources/Recommendations/RecommendationsLocalization.swift @@ -0,0 +1,36 @@ +import Foundation + +package enum RecommendationsLocalization: String { + case httpStatus = "recommendations.error.http_status" + case httpStatusWithDetails = "recommendations.error.http_status_with_details" + case mechanicCodeEmpty = "recommendations.error.mechanic_code_empty" + case missingConnection = "recommendations.error.missing_connection" + case offerContextConflict = "recommendations.error.offer_context_conflict" + case offerIDInvalid = "recommendations.error.offer_id_invalid" + case productAndOfferConflict = "recommendations.error.product_and_offer_conflict" + case productContextConflict = "recommendations.error.product_context_conflict" + case productIDInvalid = "recommendations.error.product_id_invalid" + case requestFailed = "recommendations.error.request_failed" + case blockCodeEmpty = "recommendations.error.block_code_empty" + case invalidResponse = "recommendations.error.invalid_response" + case trackingUnavailable = "recommendations.error.tracking_unavailable" + case consentRequired = "recommendations.error.consent_required" + case trackingFailed = "recommendations.error.tracking_failed" + case cancelled = "recommendations.error.cancelled" + case executionIDInvalid = "recommendations.error.execution_id_invalid" + case clickProductIDInvalid = "recommendations.error.click_product_id_invalid" + case positionInvalid = "recommendations.error.position_invalid" + case screenURIInvalid = "recommendations.error.screen_uri_invalid" + + package var localized: String { + NSLocalizedString( + rawValue, + bundle: .module, + comment: "" + ) + } + + package func format(_ arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} diff --git a/Sources/Recommendations/Resources/en.lproj/Localizable.strings b/Sources/Recommendations/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..974fa37 --- /dev/null +++ b/Sources/Recommendations/Resources/en.lproj/Localizable.strings @@ -0,0 +1,20 @@ +"recommendations.error.block_code_empty" = "Recommendation block code must not be blank"; +"recommendations.error.http_status" = "Recommendations request failed with HTTP status %d."; +"recommendations.error.http_status_with_details" = "Recommendations request failed with HTTP status %d: %@"; +"recommendations.error.missing_connection" = "Recommendation service is not connected."; +"recommendations.error.offer_context_conflict" = "Recommendation offer requires either ID or external ID"; +"recommendations.error.offer_id_invalid" = "Recommendation offer ID must be greater than zero"; +"recommendations.error.product_and_offer_conflict" = "Recommendation requires either product context or offer context"; +"recommendations.error.product_context_conflict" = "Recommendation product requires either ID or external ID"; +"recommendations.error.product_id_invalid" = "Recommendation product ID must be greater than zero"; +"recommendations.error.request_failed" = "Recommendations request failed: %@"; +"recommendations.error.invalid_response" = "Invalid recommendations response: %@"; +"recommendations.error.tracking_unavailable" = "Recommendation tracking is unavailable."; +"recommendations.error.consent_required" = "Tracking consent is required for recommendation events."; +"recommendations.error.tracking_failed" = "Could not record recommendation event: %@"; +"recommendations.error.cancelled" = "Recommendation operation was cancelled."; +"recommendations.error.execution_id_invalid" = "Recommendation execution ID must be greater than zero."; +"recommendations.error.click_product_id_invalid" = "Recommendation product ID must be between 1 and 4294967295."; +"recommendations.error.position_invalid" = "Recommendation position must be between 0 and 4294967295."; +"recommendations.error.screen_uri_invalid" = "Recommendation screen URI must be absolute."; +"recommendations.error.mechanic_code_empty" = "Recommendation mechanic code must not be empty."; diff --git a/Sources/Recommendations/Resources/es.lproj/Localizable.strings b/Sources/Recommendations/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..527f926 --- /dev/null +++ b/Sources/Recommendations/Resources/es.lproj/Localizable.strings @@ -0,0 +1,20 @@ +"recommendations.error.block_code_empty" = "El código del bloque de recomendaciones no puede estar vacío"; +"recommendations.error.http_status" = "La solicitud de recomendaciones falló con el estado HTTP %d."; +"recommendations.error.http_status_with_details" = "La solicitud de recomendaciones falló con el estado HTTP %d: %@"; +"recommendations.error.missing_connection" = "El servicio de recomendaciones no está conectado."; +"recommendations.error.offer_context_conflict" = "La oferta de la recomendación requiere el ID o el ID externo"; +"recommendations.error.offer_id_invalid" = "El ID de la oferta debe ser mayor que cero"; +"recommendations.error.product_and_offer_conflict" = "La recomendación requiere el contexto del producto o de la oferta"; +"recommendations.error.product_context_conflict" = "El producto de la recomendación requiere el ID o el ID externo"; +"recommendations.error.product_id_invalid" = "El ID del producto debe ser mayor que cero"; +"recommendations.error.request_failed" = "La solicitud de recomendaciones falló: %@"; +"recommendations.error.invalid_response" = "Respuesta de recomendaciones no válida: %@"; +"recommendations.error.tracking_unavailable" = "El seguimiento de recomendaciones no está disponible."; +"recommendations.error.consent_required" = "Se requiere consentimiento para registrar eventos de recomendaciones."; +"recommendations.error.tracking_failed" = "No se pudo registrar el evento de recomendación: %@"; +"recommendations.error.cancelled" = "Se canceló la operación de recomendaciones."; +"recommendations.error.execution_id_invalid" = "El ID de ejecución de recomendaciones debe ser mayor que cero."; +"recommendations.error.click_product_id_invalid" = "El ID del producto recomendado debe estar entre 1 y 4294967295."; +"recommendations.error.position_invalid" = "La posición de la recomendación debe estar entre 0 y 4294967295."; +"recommendations.error.screen_uri_invalid" = "La URI de la pantalla de recomendaciones debe ser absoluta."; +"recommendations.error.mechanic_code_empty" = "El código de la mecánica de recomendaciones no puede estar vacío."; diff --git a/Sources/Recommendations/Resources/ru.lproj/Localizable.strings b/Sources/Recommendations/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000..60fc3eb --- /dev/null +++ b/Sources/Recommendations/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,20 @@ +"recommendations.error.block_code_empty" = "Код блока рекомендаций не должен быть пустым"; +"recommendations.error.http_status" = "Запрос рекомендаций завершился с HTTP-статусом %d."; +"recommendations.error.http_status_with_details" = "Запрос рекомендаций завершился с HTTP-статусом %d: %@"; +"recommendations.error.missing_connection" = "Сервис рекомендаций не подключён."; +"recommendations.error.offer_context_conflict" = "Для оффера рекомендации нужно указать ID или внешний ID"; +"recommendations.error.offer_id_invalid" = "ID оффера должен быть больше нуля"; +"recommendations.error.product_and_offer_conflict" = "Для рекомендации нужно указать контекст товара или оффера"; +"recommendations.error.product_context_conflict" = "Для товара рекомендации нужно указать ID или внешний ID"; +"recommendations.error.product_id_invalid" = "ID товара должен быть больше нуля"; +"recommendations.error.request_failed" = "Запрос рекомендаций завершился ошибкой: %@"; +"recommendations.error.invalid_response" = "Некорректный ответ рекомендаций: %@"; +"recommendations.error.tracking_unavailable" = "Отслеживание рекомендаций недоступно."; +"recommendations.error.consent_required" = "Для отслеживания рекомендаций требуется согласие пользователя."; +"recommendations.error.tracking_failed" = "Не удалось записать событие рекомендации: %@"; +"recommendations.error.cancelled" = "Операция с рекомендациями отменена."; +"recommendations.error.execution_id_invalid" = "ID выполнения механики рекомендаций должен быть больше нуля."; +"recommendations.error.click_product_id_invalid" = "ID товара рекомендации должен быть в диапазоне от 1 до 4294967295."; +"recommendations.error.position_invalid" = "Позиция рекомендации должна быть в диапазоне от 0 до 4294967295."; +"recommendations.error.screen_uri_invalid" = "Адрес экрана рекомендации должен быть абсолютным URI."; +"recommendations.error.mechanic_code_empty" = "Код механики рекомендаций не должен быть пустым."; diff --git a/Sources/Recommendations/SDKResourceBundle.swift b/Sources/Recommendations/SDKResourceBundle.swift new file mode 100644 index 0000000..6887f6b --- /dev/null +++ b/Sources/Recommendations/SDKResourceBundle.swift @@ -0,0 +1,18 @@ +#if !SWIFT_PACKAGE +import Foundation + +private final class SDKResourceBundleToken {} + +extension Bundle { + static let module: Bundle = { + let moduleBundle = Bundle(for: SDKResourceBundleToken.self) + for container in [moduleBundle, Bundle.main] { + if let url = container.url(forResource: "SimlaRecommendations", withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } + } + fatalError("Missing SimlaRecommendations.bundle") + }() +} +#endif diff --git a/Sources/SDKConfiguration/BackendEndpoints.swift b/Sources/SDKConfiguration/BackendEndpoints.swift new file mode 100644 index 0000000..9a05a00 --- /dev/null +++ b/Sources/SDKConfiguration/BackendEndpoints.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Backend defaults shared by the application SDK and notification extension. +public enum SDKBackend { + public static let defaultBaseURL = "https://c.retailcrm.tech" +} + +package struct BackendEndpoints: Equatable, Sendable { + package let clientAPIEndpointURL: URL + package let clientWebSocketEndpointURL: URL + + package init(baseURL: String = SDKBackend.defaultBaseURL) { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host, !host.isEmpty, + components.query == nil, components.fragment == nil else { + preconditionFailure("baseURL must be an absolute HTTP(S) URL without query or fragment.") + } + components.scheme = scheme + while components.path.hasSuffix("/") { + components.path.removeLast() + } + if !components.path.hasSuffix("/api/client/v1") { + components.path += "/api/client/v1" + } + guard let apiURL = components.url else { + preconditionFailure("Invalid baseURL.") + } + clientAPIEndpointURL = apiURL + components.scheme = scheme == "https" ? "wss" : "ws" + components.path += "/ws" + clientWebSocketEndpointURL = components.url! + } +} diff --git a/Sources/SDKConfiguration/CrmSdkNetworkObserver.swift b/Sources/SDKConfiguration/CrmSdkNetworkObserver.swift new file mode 100644 index 0000000..5571637 --- /dev/null +++ b/Sources/SDKConfiguration/CrmSdkNetworkObserver.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Observes SDK HTTP requests without changing their execution. +/// Callbacks run asynchronously on a private serial queue, outside SDK locks. +/// The SDK retains the observer. Implementations must support use across threads. +public protocol CrmSdkNetworkObserver: Sendable { + func requestDidStart(_ event: CrmSdkNetworkRequest) + func requestDidComplete(_ event: CrmSdkNetworkResponse) + + /// Enables URLSession task metrics. Disabled by default. + var collectsMetrics: Bool { get } + /// Metrics may arrive before or after completion. Correlate them using requestID. + func requestDidCollectMetrics(_ metrics: URLSessionTaskMetrics, requestID: UUID) +} + +public extension CrmSdkNetworkObserver { + var collectsMetrics: Bool { false } + func requestDidCollectMetrics(_ metrics: URLSessionTaskMetrics, requestID: UUID) {} +} + +/// An HTTP request prepared by the SDK, including its configured request timeout. +/// Each execution, including a retry, receives a new identifier. +public struct CrmSdkNetworkRequest: Sendable { + public let id: UUID + public let request: URLRequest + public let startedAt: Date +} + +/// The transport result before HTTP status validation or response decoding. +public struct CrmSdkNetworkResponse: Sendable { + public let id: UUID + public let response: URLResponse? + /// Response bytes for data tasks. Nil for file downloads or absent data. + public let responseBody: Data? + /// Transport error, including SDK timeout and cancellation errors. + /// HTTP 4xx/5xx responses do not set this property by themselves. + public let error: (any Error)? + /// Elapsed seconds measured using a monotonic clock. + public let duration: TimeInterval +} + +/// Delivers transient events; it does not retain request history. +package final class NetworkObservationDelivery: @unchecked Sendable { + private let observer: any CrmSdkNetworkObserver + private let queue = DispatchQueue(label: "com.retailcrm.sdk.network-observer", qos: .utility) + package let collectsMetrics: Bool + + package init(observer: any CrmSdkNetworkObserver) { + self.observer = observer + self.collectsMetrics = observer.collectsMetrics + } + + package func begin(request: URLRequest) -> NetworkTaskObservation { + let event = CrmSdkNetworkRequest(id: UUID(), request: request, startedAt: Date()) + let observation = NetworkTaskObservation(id: event.id, delivery: self) + queue.async { [observer] in observer.requestDidStart(event) } + return observation + } + + fileprivate func complete(_ event: CrmSdkNetworkResponse) { + queue.async { [observer] in observer.requestDidComplete(event) } + } + + fileprivate func metrics(_ metrics: URLSessionTaskMetrics, requestID: UUID) { + queue.async { [observer] in observer.requestDidCollectMetrics(metrics, requestID: requestID) } + } +} + +package final class NetworkTaskObservation: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let id: UUID + private let delivery: NetworkObservationDelivery + private let startedAt = ProcessInfo.processInfo.systemUptime + + fileprivate init(id: UUID, delivery: NetworkObservationDelivery) { + self.id = id + self.delivery = delivery + } + + /// Task delegates are supported on iOS 15+ for foreground URL sessions. + package func attachMetrics(to task: URLSessionTask, in session: URLSession) { + guard delivery.collectsMetrics, session.configuration.identifier == nil else { return } + task.delegate = self + } + + package func finish(response: URLResponse?, data: Data?, error: Error?) { + delivery.complete(CrmSdkNetworkResponse( + id: id, response: response, responseBody: data, error: error, + duration: max(0, ProcessInfo.processInfo.systemUptime - startedAt) + )) + } + + package func urlSession( + _ session: URLSession, task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { + delivery.metrics(metrics, requestID: id) + // Preserve metrics handling installed by the application's session delegate. + (session.delegate as? URLSessionTaskDelegate)?.urlSession?( + session, task: task, didFinishCollecting: metrics + ) + } +} diff --git a/Sources/SQLiteStorage/SQLiteDatabase.swift b/Sources/SQLiteStorage/SQLiteDatabase.swift new file mode 100644 index 0000000..d46ee68 --- /dev/null +++ b/Sources/SQLiteStorage/SQLiteDatabase.swift @@ -0,0 +1,176 @@ +import Foundation +import SQLite3 + +/// A serialized connection to one database in the current process's container. +package final class SQLiteDatabase: @unchecked Sendable { + private let connection: SQLiteConnection + private let lock = NSLock() + + package static func applicationSupportURL(fileName: String) throws -> URL { + try FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true + ).appendingPathComponent(fileName) + } + + package init(url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + connection = try SQLiteConnection(url: url) + var url = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? url.setResourceValues(values) + } + + package func withConnection(_ operation: (SQLiteConnection) throws -> T) throws -> T { + lock.lock() + defer { lock.unlock() } + return try operation(connection) + } + + package func transaction(_ operation: (SQLiteConnection) throws -> T) throws -> T { + try withConnection { connection in + try connection.execute("BEGIN IMMEDIATE TRANSACTION;") + do { + let result = try operation(connection) + try connection.execute("COMMIT;") + return result + } catch { + try? connection.execute("ROLLBACK;") + throw error + } + } + } + + /// Each array entry advances user_version by one, atomically with its schema changes. + package func migrate(_ migrations: [String]) throws { + try transaction { connection in + let version = Int(try connection.query("PRAGMA user_version;")[0][0].integer ?? 0) + guard version >= 0, version <= migrations.count else { + throw SQLiteStorageError(message: "Unsupported SQLite schema version: \(version).") + } + for index in version.. [[SQLiteValue]] { + let statement = try prepare(sql, bindings: bindings) + defer { sqlite3_finalize(statement) } + var rows: [[SQLiteValue]] = [] + while true { + switch sqlite3_step(statement) { + case SQLITE_DONE: return rows + case SQLITE_ROW: + rows.append((0.. OpaquePointer { + var prepared: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &prepared, nil) == SQLITE_OK, let statement = prepared else { + sqlite3_finalize(prepared) + throw failure() + } + do { + guard sqlite3_bind_parameter_count(statement) == bindings.count else { + throw SQLiteStorageError(message: "SQLite binding count does not match the statement.") + } + for (offset, value) in bindings.enumerated() { + let index = Int32(offset + 1) + let result: Int32 + switch value { + case .null: result = sqlite3_bind_null(statement, index) + case .text(let value): + result = sqlite3_bind_text(statement, index, value, Int32(value.utf8.count), Self.transient) + case .integer(let value): result = sqlite3_bind_int64(statement, index, value) + case .real(let value): result = sqlite3_bind_double(statement, index, value) + case .blob(let value): + result = value.isEmpty ? sqlite3_bind_zeroblob(statement, index, 0) : value.withUnsafeBytes { + sqlite3_bind_blob(statement, index, $0.baseAddress, Int32($0.count), Self.transient) + } + } + guard result == SQLITE_OK else { throw failure() } + } + return statement + } catch { + sqlite3_finalize(statement) + throw error + } + } + + private func failure() -> SQLiteStorageError { + SQLiteStorageError(message: String(cString: sqlite3_errmsg(handle))) + } +} + +package struct SQLiteStorageError: Error, Equatable, LocalizedError { + package let message: String + package init(message: String) { self.message = message } + package var errorDescription: String? { message } +} diff --git a/Sources/Tracker/CrmSdk+Tracker.swift b/Sources/Tracker/CrmSdk+Tracker.swift new file mode 100644 index 0000000..d1839fa --- /dev/null +++ b/Sources/Tracker/CrmSdk+Tracker.swift @@ -0,0 +1,14 @@ +import Core + +public extension CrmSdk { + + /// Shared tracker module configured during SDK initialization. + var tracker: Tracker { + module(Tracker.self) { + Tracker( + config: config.trackerConfig, + sessionRuntime: sessionRuntime + ) + } + } +} diff --git a/Sources/Tracker/Helpers/SQLiteTrackerStorage.swift b/Sources/Tracker/Helpers/SQLiteTrackerStorage.swift new file mode 100644 index 0000000..3f7c5e0 --- /dev/null +++ b/Sources/Tracker/Helpers/SQLiteTrackerStorage.swift @@ -0,0 +1,210 @@ +import CryptoKit +import Foundation +import SQLiteStorage + +package final class SQLiteTrackerStorage { + private static let databaseFileName = "crm_consultant_tracker.sqlite" + private let database: SQLiteDatabase + private let lock = NSLock() + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private var activeScopeHash: String? + + package var hasActiveScope: Bool { + lock.withLock { activeScopeHash != nil } + } + + package convenience init() throws { + try self.init(databaseURL: SQLiteDatabase.applicationSupportURL(fileName: Self.databaseFileName)) + } + + package init(databaseURL: URL) throws { + database = try SQLiteDatabase(url: databaseURL) + try database.migrate([""" + CREATE TABLE IF NOT EXISTS tracker_events ( + scope_hash TEXT NOT NULL, + event_id TEXT NOT NULL, + position INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (scope_hash, event_id) + ); + CREATE INDEX IF NOT EXISTS tracker_events_scope_position + ON tracker_events(scope_hash, position); + CREATE TABLE IF NOT EXISTS tracker_oversized_events ( + scope_hash TEXT NOT NULL, + event_id TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (scope_hash, event_id) + ); + """]) + } + + @discardableResult + package func activateScope(siteToken: String, userToken: String) throws -> Bool { + let scope = Self.scopeHash(siteToken: siteToken, userToken: userToken) + return try lock.withLock { + guard activeScopeHash != scope else { return false } + try database.transaction { connection in + for table in ["tracker_events", "tracker_oversized_events"] { + try connection.execute("DELETE FROM \(table) WHERE scope_hash != ?;", bindings: [.text(scope)]) + } + try trimOversizedEvents(connection) + } + activeScopeHash = scope + return true + } + } + + package func loadPendingEvents() throws -> [TrackerEvent] { + try lock.withLock { + let scope = try requireScope() + return try database.withConnection { connection in + try connection.query( + "SELECT payload FROM tracker_events WHERE scope_hash = ? ORDER BY position ASC;", + bindings: [.text(scope)] + ).map { row in + guard let payload = row[0].string else { + throw SQLiteTrackerStorageError.sqlite(TrackerLocalization.sqliteEventPayloadMissing.localized) + } + return try decoder.decode(TrackerEvent.self, from: Data(payload.utf8)) + } + } + } + } + + package func savePendingEvents(_ events: [TrackerEvent]) throws { + try lock.withLock { + let scope = try requireScope() + try database.transaction { connection in + try connection.execute("DELETE FROM tracker_events WHERE scope_hash = ?;", bindings: [.text(scope)]) + try insert(events, scope: scope, connection: connection) + } + } + } + + package func appendPendingEvent(_ event: TrackerEvent, dropping ids: Set) throws { + try lock.withLock { + let scope = try requireScope() + try database.transaction { connection in + try deleteEvents(ids: ids, scope: scope, connection: connection) + let rows = try connection.query( + "SELECT COALESCE(MAX(position), -1) + 1 FROM tracker_events WHERE scope_hash = ?;", + bindings: [.text(scope)] + ) + try insert([event], scope: scope, startingPosition: rows[0][0].integer ?? 0, connection: connection) + } + } + } + + package func removePendingEvents(ids: Set) throws { + guard !ids.isEmpty else { return } + try lock.withLock { + let scope = try requireScope() + try database.transaction { connection in + try deleteEvents(ids: ids, scope: scope, connection: connection) + } + } + } + + package func moveToOversized(_ event: TrackerEvent) throws { + try lock.withLock { + let scope = try requireScope() + let payload = String(decoding: try encoder.encode(event), as: UTF8.self) + try database.transaction { connection in + try connection.execute( + "INSERT OR REPLACE INTO tracker_oversized_events (scope_hash, event_id, payload) VALUES (?, ?, ?);", + bindings: [.text(scope), .text(event.id), .text(payload)] + ) + try deleteEvents(ids: [event.id], scope: scope, connection: connection) + try trimOversizedEvents(connection) + } + } + } + + package func loadOversizedEventIDs() throws -> [String] { + try lock.withLock { + let scope = try requireScope() + return try database.withConnection { connection in + try connection.query( + "SELECT event_id FROM tracker_oversized_events WHERE scope_hash = ? ORDER BY rowid ASC;", + bindings: [.text(scope)] + ).map { row in + guard let id = row[0].string else { + throw SQLiteTrackerStorageError.sqlite(TrackerLocalization.sqliteEventPayloadMissing.localized) + } + return id + } + } + } + } + + package func clearOversizedEvents() throws { + try lock.withLock { + let scope = try requireScope() + try database.withConnection { connection in + try connection.execute("DELETE FROM tracker_oversized_events WHERE scope_hash = ?;", bindings: [.text(scope)]) + } + } + } + + /// Clears both queues before reconnecting as another customer, even if reconnect fails. + package func clearForIdentityChange() throws { + try lock.withLock { + try database.transaction { connection in + try connection.execute("DELETE FROM tracker_events;") + try connection.execute("DELETE FROM tracker_oversized_events;") + } + activeScopeHash = nil + } + } + + private func requireScope() throws -> String { + guard let activeScopeHash else { + throw SQLiteTrackerStorageError.sqlite(TrackerLocalization.storageScopeInactive.localized) + } + return activeScopeHash + } + + private func trimOversizedEvents(_ connection: SQLiteConnection) throws { + try connection.execute(""" + DELETE FROM tracker_oversized_events WHERE rowid NOT IN ( + SELECT rowid FROM tracker_oversized_events ORDER BY rowid DESC LIMIT 50 + ); + """) + } + + private func deleteEvents(ids: Set, scope: String, connection: SQLiteConnection) throws { + for id in ids { + try connection.execute( + "DELETE FROM tracker_events WHERE scope_hash = ? AND event_id = ?;", bindings: [.text(scope), .text(id)] + ) + } + } + + private func insert( + _ events: [TrackerEvent], scope: String, startingPosition: Int64 = 0, connection: SQLiteConnection + ) throws { + for (position, event) in events.enumerated() { + let payload = String(decoding: try encoder.encode(event), as: UTF8.self) + try connection.execute( + "INSERT OR REPLACE INTO tracker_events (scope_hash, event_id, position, payload) VALUES (?, ?, ?, ?);", + bindings: [.text(scope), .text(event.id), .integer(startingPosition + Int64(position)), .text(payload)] + ) + } + } + + private static func scopeHash(siteToken: String, userToken: String) -> String { + SHA256.hash(data: Data("\(siteToken)\u{0}\(userToken)".utf8)) + .map { String(format: "%02x", $0) }.joined() + } +} + +package enum SQLiteTrackerStorageError: Error, Equatable, LocalizedError { + case sqlite(String) + + package var errorDescription: String? { + switch self { + case .sqlite(let message): return message + } + } +} diff --git a/Sources/Tracker/Helpers/TrackerBatchBuilder.swift b/Sources/Tracker/Helpers/TrackerBatchBuilder.swift new file mode 100644 index 0000000..cef3949 --- /dev/null +++ b/Sources/Tracker/Helpers/TrackerBatchBuilder.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Packs whole events in order, leaving 1 KiB of headroom below the request limit. +package enum TrackerBatchBuilder { + package static let requestLimit = 1_048_576 + package static let targetSize = requestLimit - 1_024 + + /// Oversized individual events remain intact for the caller to reject. + package static func batches(_ events: [TrackerEvent], targetSize: Int = targetSize) throws -> [[TrackerEvent]] { + let encoder = JSONEncoder() + var result: [[TrackerEvent]] = [] + var current: [TrackerEvent] = [] + var size = 2 // JSON array brackets. + for event in events { + let eventSize = try encoder.encode(event).count + let separator = current.isEmpty ? 0 : 1 + if !current.isEmpty, size + separator + eventSize > targetSize { + result.append(current) + current = [] + size = 2 + } + size += (current.isEmpty ? 0 : 1) + eventSize + current.append(event) + } + if !current.isEmpty { result.append(current) } + return result + } +} diff --git a/Sources/Tracker/Helpers/TrackerEventDataFactory.swift b/Sources/Tracker/Helpers/TrackerEventDataFactory.swift new file mode 100644 index 0000000..75fcbe2 --- /dev/null +++ b/Sources/Tracker/Helpers/TrackerEventDataFactory.swift @@ -0,0 +1,189 @@ +import Foundation + +package enum TrackerEventDataFactory { + package typealias Data = [String: TrackerEventDataValue] + + package static func pageView(_ product: TrackerViewedProduct?) throws -> Data? { + guard let product else { return nil } + var data: Data = [:] + try addOffer(product.offer, to: &data) + if let productExternalID = product.productExternalID { + let value = try required(productExternalID, "product_external_id") + guard value.unicodeScalars.count <= 255 else { + throw TrackerError.invalidEvent(TrackerLocalization.identifierTooLong.format("product_external_id", 255)) + } + data["product_external_id"] = .string(value) + } + if let productID = product.productID { + guard productID > 0, productID <= Int64(UInt32.max) else { + throw TrackerError.invalidEvent(TrackerLocalization.positiveUInt32.format("productID")) + } + data["product_id"] = .int(Int(productID)) + } + if let brand = product.brand?.nilIfEmpty { data["offer_brand"] = .string(brand) } + guard !data.isEmpty else { + throw TrackerError.invalidEvent(TrackerLocalization.productIdentityRequired.localized) + } + return data + } + + package static func click(element: String, leaving: Bool) throws -> Data { + ["el": .string(try required(element, "element")), "leaving": .bool(leaving)] + } + + package static func register(customer: TrackerCustomer) throws -> Data { + var data: Data = [:] + if let value = customer.firstName?.nilIfEmpty { data["customer_first_name"] = .string(value) } + if let value = customer.lastName?.nilIfEmpty { data["customer_last_name"] = .string(value) } + if let value = customer.patronymic?.nilIfEmpty { data["customer_patronymic"] = .string(value) } + if let value = customer.email?.nilIfEmpty { data["customer_email"] = .string(value) } + if let value = customer.phone?.nilIfEmpty { data["customer_phone"] = .string(value) } + if !customer.subscriptions.isEmpty { + data["subscriptions"] = .array(try customer.subscriptions.map(subscriptionValue)) + } + guard !data.isEmpty else { + throw TrackerError.invalidEvent(TrackerLocalization.customerAttributeRequired.localized) + } + return data + } + + package static func customerEmail(_ email: String, subscriptions: [TrackerSubscription]) throws -> Data { + var data: Data = ["customer_email": .string(try required(email, "customerEmail"))] + if !subscriptions.isEmpty { + data["subscriptions"] = .array(try subscriptions.map(subscriptionValue)) + } + return data + } + + package static func openCart(customerEmail: String?) throws -> Data? { + guard let customerEmail else { return nil } + return ["customer_email": .string(try required(customerEmail, "customerEmail"))] + } + + package static func contact(_ channel: TrackerContactChannel) -> Data { + ["contact_channel": .string(channel.rawValue)] + } + + package static func cart(id: String, items: [TrackerCartItem]) throws -> Data { + var data: Data = ["cart_id": .string(try required(id, "cartID"))] + if !items.isEmpty { data["items"] = .array(try items.map(itemValue)) } + return data + } + + package static func order( + identifier: TrackerOrderIdentifier, + cartID: String?, + items: [TrackerCartItem], + deliveryPrice: Double?, + total: Double? + ) throws -> Data { + var data: Data = [:] + switch identifier { + case let .systemID(value): + guard value > 0, value <= Int64(Int32.max) else { + throw TrackerError.invalidEvent(TrackerLocalization.orderSystemIDOutOfRange.localized) + } + data["order_id"] = .int(Int(value)) + case let .externalID(value): data["order_external_id"] = .string(try required(value, "orderExternalID")) + case let .number(value): data["order_number"] = .string(try required(value, "orderNumber")) + } + if let cartID { data["cart_id"] = .string(try required(cartID, "cartID")) } + if !items.isEmpty { data["items"] = .array(try items.map(itemValue)) } + if let deliveryPrice { + data["delivery_price"] = .double(try nonNegative(deliveryPrice, "deliveryPrice")) + } + if let total { data["total"] = .double(try nonNegative(total, "total")) } + return data + } + + package static func subscription(_ subscription: TrackerSubscription) throws -> Data { + guard case let .object(data) = try subscriptionValue(subscription) else { return [:] } + return data + } + + package static func form(_ selector: String) throws -> Data { + ["form_selector": .string(try required(selector, "formSelector"))] + } + + package static func fileDownload(fileName: String, element: String?) throws -> Data { + var data: Data = ["file_name": .string(try required(fileName, "fileName"))] + if let element { data["el"] = .string(try required(element, "element")) } + return data + } + + package static func favoriteProduct(offer: TrackerOfferIdentifier, removed: Bool) throws -> Data { + var data: Data = ["removed": .bool(removed)] + try addOffer(offer, to: &data) + return data + } + + package static func code(_ value: String?) throws -> String? { + guard let value else { return nil } + return try required(value, "code") + } + + package static func absoluteURI(_ value: String, name: String) throws -> String { + let value = try required(value, name) + guard let url = URL(string: value), url.scheme != nil else { + throw TrackerError.invalidEvent(TrackerLocalization.absoluteURIRequired.format(name)) + } + return url.absoluteString + } + + private static func itemValue(_ item: TrackerCartItem) throws -> TrackerEventDataValue { + guard item.quantity > 0, item.quantity <= Int(UInt32.max) else { + throw TrackerError.invalidEvent(TrackerLocalization.positiveUInt32.format("quantity")) + } + var data: Data = [ + "price": .double(try nonNegative(item.price, "price")), + "quantity": .int(item.quantity) + ] + try addOffer(item.offer, to: &data, item: true) + return .object(data) + } + + private static func subscriptionValue(_ subscription: TrackerSubscription) throws -> TrackerEventDataValue { + .object([ + "channel": .string(subscription.channel.rawValue), + "subscription": .string(try required(subscription.address, "subscription address")), + "active": .bool(subscription.active) + ]) + } + + private static func addOffer(_ offer: TrackerOfferIdentifier?, to data: inout Data, item: Bool = false) throws { + guard let offer else { return } + switch offer { + case let .systemID(id): + guard id > 0, id <= (item ? Int64.max : Int64(UInt32.max)) else { + throw TrackerError.invalidEvent(TrackerLocalization.offerSystemIDOutOfRange.localized) + } + data[item ? "id" : "offer_id"] = .int(Int(id)) + case let .externalID(id): + let id = try required(id, "offer_external_id") + guard id.unicodeScalars.count <= 255 else { + throw TrackerError.invalidEvent(TrackerLocalization.identifierTooLong.format("offer_external_id", 255)) + } + data[item ? "external_id" : "offer_external_id"] = .string(id) + case let .xmlID(id): + let id = try required(id, "offer_xml_id") + guard id.unicodeScalars.count <= 255 else { + throw TrackerError.invalidEvent(TrackerLocalization.identifierTooLong.format("offer_xml_id", 255)) + } + data[item ? "xml_id" : "offer_xml_id"] = .string(id) + } + } + + private static func required(_ value: String, _ name: String) throws -> String { + guard let value = value.nilIfEmpty else { + throw TrackerError.invalidEvent(TrackerLocalization.fieldRequired.format(name)) + } + return value + } + + private static func nonNegative(_ value: Double, _ name: String) throws -> Double { + guard value.isFinite, value >= 0 else { + throw TrackerError.invalidEvent(TrackerLocalization.nonNegativeNumber.format(name)) + } + return value + } +} diff --git a/Sources/Tracker/Helpers/TrackerEventQueue.swift b/Sources/Tracker/Helpers/TrackerEventQueue.swift new file mode 100644 index 0000000..7fc544c --- /dev/null +++ b/Sources/Tracker/Helpers/TrackerEventQueue.swift @@ -0,0 +1,65 @@ +import Foundation + +package final class TrackerEventQueue { + private let lock = NSLock() + private var events: [TrackerEvent] = [] + private static let maxSize = 1_000 + + package var count: Int { + lock.withLock { + events.count + } + } + + package init() {} + + @discardableResult + package func append(_ event: TrackerEvent) -> [TrackerEvent] { + lock.withLock { + events.append(event) + return trimToMaxSize(Self.maxSize) + } + } + + package func snapshot() -> [TrackerEvent] { + lock.withLock { + events + } + } + + package func clear() { + lock.withLock { + events.removeAll() + } + } + + package func remove(ids: Set) { + guard !ids.isEmpty else { + return + } + + lock.withLock { + events.removeAll { ids.contains($0.id) } + } + } + + @discardableResult + package func prepend(_ eventsToRestore: [TrackerEvent]) -> [TrackerEvent] { + lock.withLock { + events = eventsToRestore + events + return trimToMaxSize(Self.maxSize) + } + } + + private func trimToMaxSize(_ maxSize: Int) -> [TrackerEvent] { + let limit = max(maxSize, 0) + guard events.count > limit else { + return [] + } + + let droppedCount = events.count - limit + let droppedEvents = Array(events.prefix(droppedCount)) + events.removeFirst(droppedCount) + return droppedEvents + } +} diff --git a/Sources/Tracker/Helpers/TrackerEventTimestamp.swift b/Sources/Tracker/Helpers/TrackerEventTimestamp.swift new file mode 100644 index 0000000..c0b9ee2 --- /dev/null +++ b/Sources/Tracker/Helpers/TrackerEventTimestamp.swift @@ -0,0 +1,19 @@ +// +// TrackerEventTimestamp.swift +// ConsultantSdk +// +// Created by Yriy Deviataev on 10.09.2026. +// + +import Foundation + +package enum TrackerEventTimestamp { + package static func make(_ date: Date = Date()) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [ + .withInternetDateTime, + .withFractionalSeconds + ] + return formatter.string(from: date) + } +} diff --git a/Sources/Tracker/Models/TrackerClientError.swift b/Sources/Tracker/Models/TrackerClientError.swift new file mode 100644 index 0000000..8eb79d9 --- /dev/null +++ b/Sources/Tracker/Models/TrackerClientError.swift @@ -0,0 +1,24 @@ +import Foundation + +package enum TrackerClientError: Error, Equatable { + case httpStatus(Int, Data, retryAfter: TimeInterval?) + + package var rateLimitRetryAfter: TimeInterval? { + guard case let .httpStatus(statusCode, _, retryAfter) = self, statusCode == 429 else { + return nil + } + + return retryAfter + } + + package var asTrackerError: TrackerError { + switch self { + case .httpStatus(0, _, _): + return .invalidResponse(TrackerLocalization.missingHTTPResponse.localized) + case let .httpStatus(status, data, retryAfter): + let message = (try? JSONSerialization.jsonObject(with: data)) + .flatMap { $0 as? [String: String] }?["error"] + return .httpStatus(status, message.map { [$0] } ?? [], retryAfter: retryAfter) + } + } +} diff --git a/Sources/Tracker/Models/TrackerEventParameters.swift b/Sources/Tracker/Models/TrackerEventParameters.swift new file mode 100644 index 0000000..2e6541e --- /dev/null +++ b/Sources/Tracker/Models/TrackerEventParameters.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Identifies an offer using one of the backend's supported identifiers. +public enum TrackerOfferIdentifier: Equatable, Sendable { + /// Internal CRM offer ID. + case systemID(Int64) + /// External offer ID. + case externalID(String) + /// Offer XML ID. + case xmlID(String) +} + +/// Optional product details attached to a page view. +public struct TrackerViewedProduct: Equatable, Sendable { + /// Offer identifier, when the viewed product has an offer. + public let offer: TrackerOfferIdentifier? + /// Internal product ID. + public let productID: Int64? + /// Offer brand. + public let brand: String? + /// External product ID, up to 255 characters. + public let productExternalID: String? + + /// Creates the product details for `trackPageView`. + /// + /// - Parameters: + /// - offer: Optional identifier of the viewed offer. + /// - productID: Internal retailCRM product ID. + /// - brand: Brand of the viewed offer. + /// - productExternalID: External retailCRM product ID. + public init( + offer: TrackerOfferIdentifier? = nil, + productID: Int64? = nil, + brand: String? = nil, + productExternalID: String? = nil + ) { + self.offer = offer + self.productID = productID + self.brand = brand + self.productExternalID = productExternalID + } +} + +/// Backend channel for a customer subscription. +public enum TrackerSubscriptionChannel: String, Equatable, Sendable { + /// Email subscription. + case email + /// SMS subscription. + case sms + /// WhatsApp Business API subscription. + case waba +} + +/// Subscription address and its current state. +public struct TrackerSubscription: Equatable, Sendable { + /// Channel used for the subscription. + public let channel: TrackerSubscriptionChannel + /// Email address, phone number, or other channel address. + public let address: String + /// Whether the subscription is active. + public let active: Bool + + /// Creates a subscription value for registration, login, consultation, or subscription events. + /// + /// - Parameters: + /// - channel: Channel of this customer subscription. + /// - address: Subscription address, such as an email or phone number. + /// - active: Whether the subscription is active. + public init(channel: TrackerSubscriptionChannel, address: String, active: Bool) { + self.channel = channel + self.address = address + self.active = active + } +} + +/// Customer attributes included in a registration event. +public struct TrackerCustomer: Equatable, Sendable { + /// Customer's first name. + public let firstName: String? + /// Customer's last name. + public let lastName: String? + /// Customer's patronymic. + public let patronymic: String? + /// Customer's email address. + public let email: String? + /// Customer's phone number. + public let phone: String? + /// Subscription states recorded with the registration. + public let subscriptions: [TrackerSubscription] + + /// Creates the attributes sent with `trackRegister`. + /// + /// - Parameters: + /// - firstName: Customer first name. + /// - lastName: Customer last name. + /// - patronymic: Customer patronymic. + /// - email: Customer email address. + /// - phone: Customer phone number. + /// - subscriptions: Subscription states associated with this customer event. + public init( + firstName: String? = nil, + lastName: String? = nil, + patronymic: String? = nil, + email: String? = nil, + phone: String? = nil, + subscriptions: [TrackerSubscription] = [] + ) { + self.firstName = firstName + self.lastName = lastName + self.patronymic = patronymic + self.email = email + self.phone = phone + self.subscriptions = subscriptions + } +} + +/// Supported channel for a contact request. +public enum TrackerContactChannel: String, Equatable, Sendable { + /// Telegram contact request. + case telegram + /// WhatsApp contact request. + case whatsapp +} + +/// One offer and its current quantity in a cart or order. +public struct TrackerCartItem: Equatable, Sendable { + /// Offer identifier. + public let offer: TrackerOfferIdentifier + /// Price per offer. + public let price: Double + /// Number of offers. + public let quantity: Int + + /// Creates an item for cart and order events. + /// + /// - Parameters: + /// - offer: Identifier of the offer in the cart or order. + /// - price: Price per offer. + /// - quantity: Quantity of this offer. + public init(offer: TrackerOfferIdentifier, price: Double, quantity: Int) { + self.offer = offer + self.price = price + self.quantity = quantity + } +} + +/// Identifies an order by internal CRM ID, external ID, or order number. +public enum TrackerOrderIdentifier: Equatable, Sendable { + /// Internal CRM order ID, from 1 through 2,147,483,647. + case systemID(Int64) + /// External order ID. + case externalID(String) + /// Order number. + case number(String) +} diff --git a/Sources/Tracker/Models/TrackerEventUpdate.swift b/Sources/Tracker/Models/TrackerEventUpdate.swift new file mode 100644 index 0000000..05de6b3 --- /dev/null +++ b/Sources/Tracker/Models/TrackerEventUpdate.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Queue and collector updates. Delivery means the collector response reported no error for the event. +/// Callbacks may arrive off the main thread. +public enum TrackerEventUpdate: Equatable, Sendable { + /// Event IDs accepted into the startup buffer or saved to the persistent queue. + case queued([String]) + /// Event IDs and the exact outgoing JSON body, without HTTP headers. + /// The payload may contain personal data. + case sending([String], payload: String) + /// Event IDs for which the collector response reported no error. + case delivered([String]) + /// Event IDs retained for a later attempt and the reason for retrying. + case retryScheduled([String], TrackerError) + /// Event ID permanently rejected by the collector or local size limit. + case rejected(String, TrackerError) + /// Event IDs removed from the queue and the reason for removal. + case dropped([String], String) +} diff --git a/Sources/Tracker/Models/TrackerPageContext.swift b/Sources/Tracker/Models/TrackerPageContext.swift new file mode 100644 index 0000000..e3a0ade --- /dev/null +++ b/Sources/Tracker/Models/TrackerPageContext.swift @@ -0,0 +1,18 @@ +import Core +import Foundation + +package struct TrackerPageContext: Equatable, Sendable { + package let pageURL: String + package let pageTitle: String? + package let referrerURL: String? + + package init( + pageURL: String, + pageTitle: String? = nil, + referrerURL: String? = nil + ) { + self.pageURL = pageURL + self.pageTitle = pageTitle?.nilIfEmpty + self.referrerURL = referrerURL?.nilIfEmpty + } +} diff --git a/Sources/Tracker/Models/TrackerRateLimit.swift b/Sources/Tracker/Models/TrackerRateLimit.swift new file mode 100644 index 0000000..1af9d01 --- /dev/null +++ b/Sources/Tracker/Models/TrackerRateLimit.swift @@ -0,0 +1,71 @@ +import Foundation + +package enum TrackerRateLimit { + + package static func retryAfter( + from response: HTTPURLResponse, + data: Data + ) -> TimeInterval? { + retryAfter(fromHeader: response.value(forHTTPHeaderField: "Retry-After")) + ?? retryAfter(fromBody: data) + } + + private static func retryAfter(fromHeader value: String?) -> TimeInterval? { + guard let value else { + return nil + } + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + if let seconds = TimeInterval(trimmed) { + return max(seconds, 0) + } + + guard let date = makeHTTPDateFormatter().date(from: trimmed) else { + return nil + } + + return max(date.timeIntervalSinceNow, 0) + } + + private static func retryAfter(fromBody data: Data) -> TimeInterval? { + guard + !data.isEmpty, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + + return retryAfterMilliseconds(object["retry_after_ms"]) + ?? retryAfterMilliseconds(object["retry-after"]) + } + + private static func retryAfterMilliseconds(_ value: Any?) -> TimeInterval? { + let milliseconds: Double? + switch value { + case let value as NSNumber: + milliseconds = value.doubleValue + case let value as String: + milliseconds = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + milliseconds = nil + } + + guard let milliseconds, milliseconds >= 0 else { + return nil + } + + return milliseconds / 1_000 + } + + private static func makeHTTPDateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z" + return formatter + } +} diff --git a/Sources/Tracker/Models/TrackerServerTimeResponse.swift b/Sources/Tracker/Models/TrackerServerTimeResponse.swift new file mode 100644 index 0000000..1b4bf9a --- /dev/null +++ b/Sources/Tracker/Models/TrackerServerTimeResponse.swift @@ -0,0 +1,13 @@ +import Foundation + +package struct TrackerServerTimeResponse: Decodable, Equatable, Sendable { + package let timestamp: String? + + private enum CodingKeys: String, CodingKey { + case timestamp = "ts" + } + + package init(timestamp: String?) { + self.timestamp = timestamp + } +} diff --git a/Sources/Tracker/Models/TrackerStoreEventsResponse.swift b/Sources/Tracker/Models/TrackerStoreEventsResponse.swift new file mode 100644 index 0000000..951e59b --- /dev/null +++ b/Sources/Tracker/Models/TrackerStoreEventsResponse.swift @@ -0,0 +1,93 @@ +import Core +import Foundation + +package struct TrackerStoreEventsResponse: Decodable, Equatable, Sendable { + package let eventErrors: [String: [String: String]] + package let topLevelError: String? + + package init(eventErrors: [String: [String: String]] = [:], topLevelError: String? = nil) { + self.eventErrors = eventErrors + self.topLevelError = topLevelError + } + + package func failures(attemptedEventIDs: Set) -> TrackerEventFailures { + TrackerEventFailures( + response: self, + attemptedEventIDs: attemptedEventIDs + ) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let topLevel = try? container.decode(TopLevelError.self) { + self.eventErrors = [:] + self.topLevelError = topLevel.error + return + } + + self.eventErrors = try container.decode([String: [String: String]].self) + self.topLevelError = nil + } + + private struct TopLevelError: Decodable { + let error: String + } +} + +package struct TrackerEventFailures: Equatable, Sendable { + package let retryableIDs: Set + package let rejectedReasons: [String: String] + + package init( + retryableIDs: Set = [], + rejectedReasons: [String: String] = [:] + ) { + self.retryableIDs = retryableIDs + self.rejectedReasons = rejectedReasons + } + + package init( + response: TrackerStoreEventsResponse, + attemptedEventIDs: Set + ) { + + let tryAgainLater = "try again later" + + if let topLevelError = response.topLevelError?.nilIfEmpty { + if topLevelError.lowercased() == tryAgainLater { + self.init(retryableIDs: attemptedEventIDs) + } else { + self.init( + rejectedReasons: Dictionary(uniqueKeysWithValues: attemptedEventIDs.map { ($0, topLevelError) }) + ) + } + return + } + + var retryableIDs = Set() + var rejectedReasons: [String: String] = [:] + + for (eventID, errors) in response.eventErrors where attemptedEventIDs.contains(eventID) { + let normalizedErrors = errors + .mapValues { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.value.isEmpty } + let hasRetryableError = normalizedErrors.values.contains { + $0.lowercased() == tryAgainLater + } + + if hasRetryableError { + retryableIDs.insert(eventID) + } else if !normalizedErrors.isEmpty { + rejectedReasons[eventID] = normalizedErrors + .sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)" } + .joined(separator: ", ") + } + } + + self.init( + retryableIDs: retryableIDs, + rejectedReasons: rejectedReasons + ) + } +} diff --git a/Sources/Tracker/Resources/en.lproj/Localizable.strings b/Sources/Tracker/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..295cd3a --- /dev/null +++ b/Sources/Tracker/Resources/en.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"tracker.error.client_http_status" = "Tracker request failed with HTTP status %d."; +"tracker.error.client_http_status_with_retry" = "Tracker request failed with HTTP status %d. Retry after %.0f seconds."; +"tracker.error.invalid_event" = "Invalid tracker event: %@"; +"tracker.error.storage" = "Tracker storage error: %@"; +"tracker.error.request_encoding_failed" = "Could not encode the tracker request: %@"; +"tracker.error.request_failed" = "Could not send tracker events: %@"; +"tracker.error.invalid_response" = "Invalid tracker server response: %@"; +"tracker.error.event_rejected" = "The server rejected the event: %@"; +"tracker.error.flush_incomplete" = "Could not send all events: %d."; +"tracker.error.server_details" = "Server details: %@"; +"tracker.error.positive_uint32" = "The %@ field must be a positive UInt32 value."; +"tracker.error.product_identity_required" = "The product needs an offer ID, product ID, external product ID, or brand."; +"tracker.error.customer_attribute_required" = "Provide at least one customer attribute or subscription."; +"tracker.error.field_required" = "The %@ field must not be empty."; +"tracker.error.absolute_uri_required" = "The %@ field must be an absolute URI."; +"tracker.error.offer_system_id_out_of_range" = "The offer system ID is out of range."; +"tracker.error.identifier_too_long" = "The %@ field must not exceed %d characters."; +"tracker.error.non_negative_number" = "The %@ field must be a finite non-negative number."; +"tracker.error.event_too_large" = "The SDK rejected the event: the JSON request size of %lld bytes exceeds the limit of %lld bytes."; +"tracker.error.server_retry" = "The server requested a retry."; +"tracker.error.storage_unavailable" = "Tracker storage is unavailable."; +"tracker.error.missing_http_response" = "The server did not return an HTTP response."; +"tracker.error.storage_scope_inactive" = "The tracker storage scope is not active."; +"tracker.error.storage_open_failed" = "Could not open tracker storage."; +"tracker.update.tracking_consent_unavailable" = "Tracking consent is unavailable."; +"tracker.update.queue_capacity_exceeded" = "The tracker queue is full."; +"tracker.error.sqlite_event_payload_missing" = "Tracker event payload is missing."; +"tracker.error.unavailable" = "Tracker is unavailable."; +"tracker.error.consent_required" = "Tracking consent is required."; +"tracker.error.missing_connection" = "Tracker is not connected."; +"tracker.error.order_system_id_out_of_range" = "The internal order ID must be between 1 and 2147483647."; diff --git a/Sources/Tracker/Resources/es.lproj/Localizable.strings b/Sources/Tracker/Resources/es.lproj/Localizable.strings new file mode 100644 index 0000000..cc4f0ae --- /dev/null +++ b/Sources/Tracker/Resources/es.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"tracker.error.client_http_status" = "La solicitud del tracker falló con el estado HTTP %d."; +"tracker.error.client_http_status_with_retry" = "La solicitud del tracker falló con el estado HTTP %d. Reintentar después de %.0f segundos."; +"tracker.error.invalid_event" = "Evento del tracker no válido: %@"; +"tracker.error.storage" = "Error del almacenamiento del tracker: %@"; +"tracker.error.request_encoding_failed" = "No se pudo codificar la solicitud del tracker: %@"; +"tracker.error.request_failed" = "No se pudieron enviar los eventos del tracker: %@"; +"tracker.error.invalid_response" = "Respuesta no válida del servidor del tracker: %@"; +"tracker.error.event_rejected" = "El servidor rechazó el evento: %@"; +"tracker.error.flush_incomplete" = "No se pudieron enviar todos los eventos: %d."; +"tracker.error.server_details" = "Detalles del servidor: %@"; +"tracker.error.positive_uint32" = "El campo %@ debe ser un valor UInt32 positivo."; +"tracker.error.product_identity_required" = "El producto necesita un ID de oferta, un ID de producto, un ID externo de producto o una marca."; +"tracker.error.customer_attribute_required" = "Indique al menos un dato del cliente o una suscripción."; +"tracker.error.field_required" = "El campo %@ no puede estar vacío."; +"tracker.error.absolute_uri_required" = "El campo %@ debe ser un URI absoluto."; +"tracker.error.offer_system_id_out_of_range" = "El ID interno de la oferta está fuera del rango permitido."; +"tracker.error.identifier_too_long" = "El campo %@ no puede superar %d caracteres."; +"tracker.error.non_negative_number" = "El campo %@ debe ser un número finito no negativo."; +"tracker.error.event_too_large" = "El SDK rechazó el evento: el tamaño de la solicitud JSON de %lld bytes supera el límite de %lld bytes."; +"tracker.error.server_retry" = "El servidor solicitó reintentar el envío."; +"tracker.error.storage_unavailable" = "El almacenamiento del tracker no está disponible."; +"tracker.error.missing_http_response" = "El servidor no devolvió una respuesta HTTP."; +"tracker.error.storage_scope_inactive" = "El ámbito de almacenamiento del tracker no está activo."; +"tracker.error.storage_open_failed" = "No se pudo abrir el almacenamiento del tracker."; +"tracker.update.tracking_consent_unavailable" = "No hay consentimiento para el seguimiento."; +"tracker.update.queue_capacity_exceeded" = "La cola del tracker está llena."; +"tracker.error.sqlite_event_payload_missing" = "Faltan los datos del evento del tracker."; +"tracker.error.unavailable" = "El tracker no está disponible."; +"tracker.error.consent_required" = "Se requiere consentimiento para el seguimiento."; +"tracker.error.missing_connection" = "El tracker no está conectado."; +"tracker.error.order_system_id_out_of_range" = "El ID interno del pedido debe estar entre 1 y 2147483647."; diff --git a/Sources/Tracker/Resources/ru.lproj/Localizable.strings b/Sources/Tracker/Resources/ru.lproj/Localizable.strings new file mode 100644 index 0000000..101d278 --- /dev/null +++ b/Sources/Tracker/Resources/ru.lproj/Localizable.strings @@ -0,0 +1,31 @@ +"tracker.error.client_http_status" = "Запрос трекера завершился с HTTP-статусом %d."; +"tracker.error.client_http_status_with_retry" = "Запрос трекера завершился с HTTP-статусом %d. Повторите через %.0f секунд."; +"tracker.error.invalid_event" = "Некорректное событие трекера: %@"; +"tracker.error.storage" = "Ошибка хранилища трекера: %@"; +"tracker.error.request_encoding_failed" = "Не удалось сформировать запрос трекера: %@"; +"tracker.error.request_failed" = "Не удалось отправить события трекера: %@"; +"tracker.error.invalid_response" = "Некорректный ответ сервера трекера: %@"; +"tracker.error.event_rejected" = "Сервер отклонил событие: %@"; +"tracker.error.flush_incomplete" = "Не удалось отправить все события: %d."; +"tracker.error.server_details" = "Подробности от сервера: %@"; +"tracker.error.positive_uint32" = "Поле %@ должно быть положительным числом UInt32."; +"tracker.error.product_identity_required" = "Для товара нужен идентификатор предложения, идентификатор товара, внешний идентификатор товара или бренд."; +"tracker.error.customer_attribute_required" = "Укажите хотя бы одно поле клиента или подписку."; +"tracker.error.field_required" = "Поле %@ не должно быть пустым."; +"tracker.error.absolute_uri_required" = "Поле %@ должно содержать абсолютный URI."; +"tracker.error.offer_system_id_out_of_range" = "Внутренний идентификатор предложения вне допустимого диапазона."; +"tracker.error.identifier_too_long" = "Поле %@ не должно превышать %d символов."; +"tracker.error.non_negative_number" = "Поле %@ должно быть конечным неотрицательным числом."; +"tracker.error.event_too_large" = "SDK отклонил событие: размер JSON-запроса %lld байт превышает лимит %lld байт."; +"tracker.error.server_retry" = "Сервер запросил повторную отправку."; +"tracker.error.storage_unavailable" = "Хранилище трекера недоступно."; +"tracker.error.missing_http_response" = "Сервер не вернул ответ HTTP."; +"tracker.error.storage_scope_inactive" = "Область хранения трекера не активна."; +"tracker.error.storage_open_failed" = "Не удалось открыть хранилище трекера."; +"tracker.update.tracking_consent_unavailable" = "Согласие на отслеживание отсутствует."; +"tracker.update.queue_capacity_exceeded" = "Очередь трекера переполнена."; +"tracker.error.sqlite_event_payload_missing" = "Отсутствуют данные события трекера."; +"tracker.error.unavailable" = "Трекер недоступен."; +"tracker.error.consent_required" = "Для отслеживания требуется согласие пользователя."; +"tracker.error.missing_connection" = "Трекер не подключён."; +"tracker.error.order_system_id_out_of_range" = "Внутренний ID заказа должен быть в диапазоне от 1 до 2147483647."; diff --git a/Sources/Tracker/SDKResourceBundle.swift b/Sources/Tracker/SDKResourceBundle.swift new file mode 100644 index 0000000..46eb49e --- /dev/null +++ b/Sources/Tracker/SDKResourceBundle.swift @@ -0,0 +1,18 @@ +#if !SWIFT_PACKAGE +import Foundation + +private final class SDKResourceBundleToken {} + +extension Bundle { + static let module: Bundle = { + let moduleBundle = Bundle(for: SDKResourceBundleToken.self) + for container in [moduleBundle, Bundle.main] { + if let url = container.url(forResource: "SimlaTracker", withExtension: "bundle"), + let bundle = Bundle(url: url) { + return bundle + } + } + fatalError("Missing SimlaTracker.bundle") + }() +} +#endif diff --git a/Sources/Tracker/State/TrackerConsentState.swift b/Sources/Tracker/State/TrackerConsentState.swift new file mode 100644 index 0000000..d9d2b42 --- /dev/null +++ b/Sources/Tracker/State/TrackerConsentState.swift @@ -0,0 +1,12 @@ +/// User consent state for tracker events. +public enum TrackerConsentState: Equatable, Sendable { + + /// Consent has not been explicitly granted or denied. + case unknown + + /// User granted permission to send tracker events. + case granted + + /// User denied permission to send tracker events. + case denied +} diff --git a/Sources/Tracker/State/TrackerState.swift b/Sources/Tracker/State/TrackerState.swift new file mode 100644 index 0000000..9242c67 --- /dev/null +++ b/Sources/Tracker/State/TrackerState.swift @@ -0,0 +1,32 @@ +/// Current mutable tracker state. +package struct TrackerState: Equatable, Sendable { + + /// retailCRM customer ID used for tracker event attribution. + package var systemCustomerID: Int64? + + /// External site customer ID used for session and tracker event attribution. + package var siteCustomerID: String? + + /// Indicates whether tracker events are accepted for the connected site. + package var isAvailable: Bool + + /// Indicates whether tracker events require explicit consent. + package var requiresConsent: Bool + + /// User consent state for tracker event collection. + package var consentState: TrackerConsentState + + package init( + systemCustomerID: Int64? = nil, + siteCustomerID: String? = nil, + isAvailable: Bool = false, + requiresConsent: Bool = false, + consentState: TrackerConsentState = .unknown + ) { + self.systemCustomerID = systemCustomerID + self.siteCustomerID = siteCustomerID + self.isAvailable = isAvailable + self.requiresConsent = requiresConsent + self.consentState = consentState + } +} diff --git a/Sources/Tracker/State/TrackerStateStore.swift b/Sources/Tracker/State/TrackerStateStore.swift new file mode 100644 index 0000000..a8c6eaf --- /dev/null +++ b/Sources/Tracker/State/TrackerStateStore.swift @@ -0,0 +1,55 @@ +import Core +import Foundation + +/// Thread-safe storage for mutable tracker settings. +package final class TrackerStateStore: @unchecked Sendable { + + private let lock = NSLock() + private var state = TrackerState() + + package var currentState: TrackerState { + lock.withLock { + state + } + } + + package init() {} + + @discardableResult + package func setSystemCustomerID(_ id: Int64?) -> Int64? { + lock.withLock { + guard let id else { + state.systemCustomerID = nil + return nil + } + + guard id > 0 else { + return state.systemCustomerID + } + + state.systemCustomerID = id + return id + } + } + + @discardableResult + package func setSiteCustomerID(_ id: String?) -> String? { + lock.withLock { + state.siteCustomerID = id?.nilIfEmpty + return state.siteCustomerID + } + } + + package func setAvailability(_ isAvailable: Bool, requiresConsent: Bool) { + lock.withLock { + state.isAvailable = isAvailable + state.requiresConsent = requiresConsent + } + } + + package func setConsent(_ consent: TrackerConsentState) { + lock.withLock { + state.consentState = consent + } + } +} diff --git a/Sources/Tracker/TimeSync/TrackerTimeSyncState.swift b/Sources/Tracker/TimeSync/TrackerTimeSyncState.swift new file mode 100644 index 0000000..973dded --- /dev/null +++ b/Sources/Tracker/TimeSync/TrackerTimeSyncState.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Tracker server time synchronization state. +package struct TrackerTimeSyncState: Equatable, Sendable { + + /// Server timestamp received from `connect.ts` or `GET /ts`. + package let serverTimestamp: Date + + /// Local date when `serverTimestamp` was received. + package let syncedAt: Date + + package init(serverTimestamp: Date, syncedAt: Date) { + self.serverTimestamp = serverTimestamp + self.syncedAt = syncedAt + } +} diff --git a/Sources/Tracker/TimeSync/TrackerTimeSyncStore.swift b/Sources/Tracker/TimeSync/TrackerTimeSyncStore.swift new file mode 100644 index 0000000..b23f81b --- /dev/null +++ b/Sources/Tracker/TimeSync/TrackerTimeSyncStore.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Thread-safe storage for tracker server time synchronization. +package final class TrackerTimeSyncStore: @unchecked Sendable { + + private let lock = NSLock() + private var state: TrackerTimeSyncState? + + package var currentState: TrackerTimeSyncState? { + lock.withLock { + state + } + } + + package init() {} + + package func setServerTimestamp(_ timestamp: String?, syncedAt: Date = Date()) { + guard + let timestamp, + let serverTimestamp = Self.date(from: timestamp) + else { + return + } + + lock.withLock { + state = TrackerTimeSyncState( + serverTimestamp: serverTimestamp, + syncedAt: syncedAt + ) + } + } + + package func clear() { + lock.withLock { + state = nil + } + } + + package func shouldRefresh(now: Date = Date(), interval: TimeInterval) -> Bool { + lock.withLock { + guard let state else { + return true + } + + return now.timeIntervalSince(state.syncedAt) >= interval + } + } + + package func resolvedServerDate(now: Date = Date()) -> Date { + lock.withLock { + guard let state else { + return now + } + + let elapsed = max(0, now.timeIntervalSince(state.syncedAt)) + return state.serverTimestamp.addingTimeInterval(elapsed) + } + } + + private static func date(from timestamp: String) -> Date? { + let timestamp = timestamp.trimmingCharacters(in: .whitespacesAndNewlines) + guard !timestamp.isEmpty else { + return nil + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [ + .withInternetDateTime, + .withFractionalSeconds + ] + if let date = formatter.date(from: timestamp) { + return date + } + + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: timestamp) + } +} diff --git a/Sources/Tracker/Tracker.swift b/Sources/Tracker/Tracker.swift new file mode 100644 index 0000000..03808d8 --- /dev/null +++ b/Sources/Tracker/Tracker.swift @@ -0,0 +1,1672 @@ +import Core +import Foundation + +/// Entry point for tracker events and their delivery state. +public final class Tracker { + + private let config: TrackerConfig + private let sessionRuntime: SessionRuntime + private let trackerStateStore = TrackerStateStore() + private let trackerTimeSyncStore = TrackerTimeSyncStore() + private let client: TrackerClient + private let eventQueue: TrackerEventQueue + private let eventRecordingQueue = DispatchQueue(label: "com.retailcrm.consultant.tracker.record", qos: .utility) + private var storage: SQLiteTrackerStorage? + private let stateLock = NSLock() + private var rateLimitedUntil: Date? + private var effectiveBatchSize: Int + private var acceptsEvents = false + private var startupEvents: [TrackerEvent] = [] + private var canBufferStartupEvents = true + private var connectedUserToken: String? + private static let startupEventLimit = 1_000 + private var collectionGeneration: UInt64 = 0 + private var operationTask: Task? + private var initializationGeneration: UInt64 = 0 + private var flushTasks: [UUID: Task, Never>] = [:] + private var flushAllTasks: [UUID: Task] = [:] + private var flushAllProgress: [UUID: TrackerFlushAllProgress] = [:] + private var periodicFlushTask: Task? + private var currentPageContext: TrackerPageContext? + private var eventUpdateHandler: ((TrackerEventUpdate) -> Void)? + private var readyUpdateHandler: ((Bool, TrackerError?) -> Void)? + private var readyResult: Result? + private var readyRevision: UInt64 = 0 + private var deliveredReadyRevision: UInt64? + private var storageInitializationError: TrackerError? + private var activeUserToken: String? + private var needsStorageReset = false + + private var canCollectEvents: Bool { + stateLock.withLock { + acceptsEvents && canCollectEventsForCurrentSession + } + } + + private var canCollectEventsForCurrentSession: Bool { + let state = trackerStateStore.currentState + guard state.consentState != .denied else { + return false + } + guard state.isAvailable else { + return false + } + guard !state.requiresConsent else { + return state.consentState == .granted + } + return true + } + + private var isRateLimited: Bool { + guard let rateLimitedUntil else { + return false + } + guard rateLimitedUntil <= Date() else { + return true + } + self.rateLimitedUntil = nil + return false + } + + package init( + config: TrackerConfig, + sessionRuntime: SessionRuntime, + storage: SQLiteTrackerStorage? = nil, + client: TrackerClient? = nil + ) { + self.config = config + self.effectiveBatchSize = min(config.batchSize, 50) + self.sessionRuntime = sessionRuntime + self.client = client ?? TrackerClient(sessionRuntime: sessionRuntime) + self.eventQueue = TrackerEventQueue() + self.storage = storage + Task { [weak self, sessionRuntime] in + guard let self else { return } + await sessionRuntime.addLifecycleListener(self) + } + } + + deinit { + periodicFlushTask?.cancel() + operationTask?.cancel() + flushTasks.values.forEach { $0.cancel() } + flushAllTasks.values.forEach { $0.cancel() } + } + + /// Whether tracking requires explicit consent for the connected session. + package var requiresConsent: Bool { trackerStateStore.currentState.requiresConsent } + + /// Current consent decision. The host application must restore its saved decision after launch. + public var consentState: TrackerConsentState { trackerStateStore.currentState.consentState } + + package var pendingEventCount: Int { eventQueue.count } + + /// Receives future queue and delivery updates. Callbacks may arrive off the main thread. + public var onEventUpdate: ((TrackerEventUpdate) -> Void)? { + get { stateLock.withLock { eventUpdateHandler } } + set { stateLock.withLock { eventUpdateHandler = newValue } } + } + + /// Reports preparation on the main thread: whether consent UI is needed and an optional error. + /// Preparation starts automatically when the shared session connects. Late subscribers receive + /// the current result; consent changes update the flag. No result is emitted while awaiting connection. + public var onReadyUpdate: ((Bool, TrackerError?) -> Void)? { + get { stateLock.withLock { readyUpdateHandler } } + set { + stateLock.withLock { + readyUpdateHandler = newValue + readyRevision &+= 1 + } + notifyReadyHandler() + } + } + + private func notifyReadyHandler() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let update = self.stateLock.withLock { () -> (((Bool, TrackerError?) -> Void), Result)? in + guard let handler = self.readyUpdateHandler, let result = self.readyResult, + self.deliveredReadyRevision != self.readyRevision else { return nil } + self.deliveredReadyRevision = self.readyRevision + return (handler, result) + } + guard let (handler, result) = update else { return } + switch result { + case .success(let requiresForm): handler(requiresForm, nil) + case .failure(let error): handler(false, error) + } + } + } + + private func publishReadiness(error: TrackerError? = nil, generation: UInt64? = nil) { + let changed = stateLock.withLock { () -> Bool in + if let generation, generation != initializationGeneration { return false } + let state = trackerStateStore.currentState + let result: Result = error.map { .failure($0) } + ?? .success(state.requiresConsent && state.consentState == .unknown) + guard readyResult != result else { return false } + readyResult = result + readyRevision &+= 1 + return true + } + if changed { notifyReadyHandler() } + } + + /// Sets the user's tracking decision and clears queued events when tracking is denied. + /// The host application owns persistence of this decision. + /// + /// - Parameters: + /// - consent: User decision about collection of tracker events. + @discardableResult + public func setConsent(_ consent: TrackerConsentState) -> Result { + let readyChanged = stateLock.withLock { () -> Bool in + trackerStateStore.setConsent(consent) + guard case .success? = readyResult else { return false } + let state = trackerStateStore.currentState + let result: Result = .success(state.requiresConsent && state.consentState == .unknown) + guard readyResult != result else { return false } + readyResult = result + readyRevision &+= 1 + return true + } + defer { if readyChanged { notifyReadyHandler() } } + if consent == .denied || (requiresConsent && consent != .granted) { + let (dropped, error) = stateLock.withLock { () -> ([TrackerEvent], TrackerError?) in + collectionGeneration &+= 1 + flushTasks.values.forEach { $0.cancel() } + flushAllTasks.values.forEach { $0.cancel() } + periodicFlushTask?.cancel() + periodicFlushTask = nil + let events = eventQueue.snapshot() + startupEvents + startupEvents.removeAll() + currentPageContext = nil + eventQueue.clear() + do { + if let storage, storage.hasActiveScope { + try storage.savePendingEvents([]) + try storage.clearOversizedEvents() + } + return (events, nil) + } catch { + return (events, error as? TrackerError ?? .storage(error.localizedDescription)) + } + } + if let error { + publish(.retryScheduled(dropped.map(\.id), error)) + return .failure(error) + } + if !dropped.isEmpty { + publish(.dropped(dropped.map(\.id), TrackerLocalization.trackingConsentUnavailable.localized)) + } + } else { + startPeriodicFlushIfNeeded() + } + return .success(()) + } + + /// Records a page view and returns its accepted event ID (startup buffer or persistent queue). + public func trackPageView( + screenURI: String, + title: String? = nil, + product: TrackerViewedProduct? = nil, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackPageView( + screenURI: screenURI, title: title, product: product, code: code, + completion: completion + ) + } + } + + /// Records a click and returns its accepted event ID. + public func trackClick( + element: String, + leaving: Bool = false, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackClick(element: element, leaving: leaving, code: code, completion: completion) + } + } + + /// Records registration and returns its accepted event ID. + public func trackRegister( + customer: TrackerCustomer, + createCustomer: Bool = false, + createCustomerByEmail: Bool = false, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackRegister( + customer: customer, createCustomer: createCustomer, + createCustomerByEmail: createCustomerByEmail, code: code, + completion: completion + ) + } + } + + /// Records a login and returns its accepted event ID. + public func trackLogin( + customerEmail: String, + subscriptions: [TrackerSubscription] = [], + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackLogin( + customerEmail: customerEmail, subscriptions: subscriptions, code: code, + completion: completion + ) + } + } + + /// Records a consultant contact and returns its accepted event ID. + public func trackConsult( + customerEmail: String, + subscriptions: [TrackerSubscription] = [], + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackConsult( + customerEmail: customerEmail, subscriptions: subscriptions, code: code, + completion: completion + ) + } + } + + /// Records a cart opening and returns its accepted event ID. + public func trackOpenCart( + customerEmail: String? = nil, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackOpenCart(customerEmail: customerEmail, code: code, completion: completion) + } + } + + /// Records a cart change and returns its accepted event ID. + public func trackCart( + cartID: String, + items: [TrackerCartItem] = [], + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackCart(cartID: cartID, items: items, code: code, completion: completion) + } + } + + /// Records a contact request and returns its accepted event ID. + public func trackContact( + channel: TrackerContactChannel, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackContact(channel: channel, code: code, completion: completion) + } + } + + /// Records an order and returns its accepted event ID. + public func trackOrder( + identifier: TrackerOrderIdentifier, + cartID: String? = nil, + items: [TrackerCartItem] = [], + deliveryPrice: Double? = nil, + total: Double? = nil, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackOrder( + identifier: identifier, cartID: cartID, items: items, + deliveryPrice: deliveryPrice, total: total, code: code, + completion: completion + ) + } + } + + /// Records a subscription change and returns its accepted event ID. + public func trackSubscription( + _ subscription: TrackerSubscription, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackSubscription(subscription, code: code, completion: completion) + } + } + + /// Records a form start and returns its accepted event ID. + public func trackFormStart( + formSelector: String, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackFormStart(formSelector: formSelector, code: code, completion: completion) + } + } + + /// Records a form submission and returns its accepted event ID. + public func trackFormSubmit( + formSelector: String, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackFormSubmit(formSelector: formSelector, code: code, completion: completion) + } + } + + /// Records a file download and returns its accepted event ID. + public func trackFileDownload( + fileName: String, + element: String? = nil, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackFileDownload( + fileName: fileName, element: element, code: code, + completion: completion + ) + } + } + + /// Records a favorite product change and returns its accepted event ID. + public func trackFavoriteProduct( + offer: TrackerOfferIdentifier, + removed: Bool = false, + code: String? = nil + ) async throws -> String { + try await awaitCompletion { completion in + trackFavoriteProduct( + offer: offer, removed: removed, code: code, + completion: completion + ) + } + } + + /// Attempts to send all events in the current queue snapshot. + public func flushAll() async throws { + try await awaitCompletion { completion in + flushAll(completion: completion) + } + } + + private func awaitCompletion( + _ register: (@escaping (Result) -> Void) -> Void + ) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + register { result in + continuation.resume(with: result) + } + } + } + +} + +/// Completion-based tracker API. +public extension Tracker { + + /// Records a page view and uses its absolute URI as the context for later events. + /// + /// - Parameters: + /// - screenURI: Absolute URI of the screen where the event occurred. + /// - title: Optional screen title. + /// - product: Optional product details associated with the page view. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackPageView( + screenURI: String, + title: String? = nil, + product: TrackerViewedProduct? = nil, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueRecord( + { [self] in + return record(.pageView, code: code) { + let uri = try TrackerEventDataFactory.absoluteURI(screenURI, name: "screenURI") + let data = try TrackerEventDataFactory.pageView(product) + let previous = stateLock.withLock { currentPageContext } + let context = TrackerPageContext( + pageURL: uri, pageTitle: title, referrerURL: previous?.pageURL + ) + return (data, context) + } + }, + completion: completion + ) + } + + /// Records a click on the element identified by `element`. + /// + /// - Parameters: + /// - element: Identifier of the clicked UI element. + /// - leaving: Whether the click leaves the current screen. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackClick( + element: String, + leaving: Bool = false, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .click, + code: code, + makeData: { + (try TrackerEventDataFactory.click(element: element, leaving: leaving), nil) + }, + completion: completion + ) + } + + /// Records registration. Set `createCustomer` to request CRM customer creation. + /// Use `createCustomerByEmail` with it when no site customer ID is available. + /// + /// - Parameters: + /// - customer: Customer attributes to include in the registration event. + /// - createCustomer: Whether to request creation of a CRM customer. + /// - createCustomerByEmail: Whether to create that CRM customer by email when no site customer ID exists. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackRegister( + customer: TrackerCustomer, + createCustomer: Bool = false, + createCustomerByEmail: Bool = false, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .register, + code: code, + createCustomer: createCustomer, + createCustomerByEmail: createCustomerByEmail, + makeData: { + (try TrackerEventDataFactory.register(customer: customer), nil) + }, + completion: completion + ) + } + + /// Records a customer login using the supplied email address. + /// + /// - Parameters: + /// - customerEmail: Email address of the customer associated with the event. + /// - subscriptions: Subscription states associated with this customer event. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackLogin( + customerEmail: String, + subscriptions: [TrackerSubscription] = [], + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .login, + code: code, + makeData: { + (try TrackerEventDataFactory.customerEmail(customerEmail, subscriptions: subscriptions), nil) + }, + completion: completion + ) + } + + /// Records an attempt to contact a consultant. + /// + /// - Parameters: + /// - customerEmail: Email address of the customer associated with the event. + /// - subscriptions: Subscription states associated with this customer event. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackConsult( + customerEmail: String, + subscriptions: [TrackerSubscription] = [], + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .consult, + code: code, + makeData: { + (try TrackerEventDataFactory.customerEmail(customerEmail, subscriptions: subscriptions), nil) + }, + completion: completion + ) + } + + /// Records a cart opening, optionally with the customer's email address. + /// + /// - Parameters: + /// - customerEmail: Email address of the customer associated with the event. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackOpenCart( + customerEmail: String? = nil, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .openCart, + code: code, + makeData: { + (try TrackerEventDataFactory.openCart(customerEmail: customerEmail), nil) + }, + completion: completion + ) + } + + /// Records the current cart contents after a cart change. + /// + /// - Parameters: + /// - cartID: Cart identifier associated with the event. + /// - items: Offers and quantities in the cart or order. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackCart( + cartID: String, + items: [TrackerCartItem] = [], + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .cart, + code: code, + makeData: { + (try TrackerEventDataFactory.cart(id: cartID, items: items), nil) + }, + completion: completion + ) + } + + /// Records a contact request through the selected channel. + /// + /// - Parameters: + /// - channel: Channel through which the customer requested contact. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackContact( + channel: TrackerContactChannel, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .contact, + code: code, + makeData: { + (TrackerEventDataFactory.contact(channel), nil) + }, + completion: completion + ) + } + + /// Records an order with its identifier and optional cart and price details. + /// + /// - Parameters: + /// - identifier: External order ID or order number. + /// - cartID: Cart identifier associated with the event. + /// - items: Offers and quantities in the cart or order. + /// - deliveryPrice: Delivery cost associated with the order. + /// - total: Order total. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackOrder( + identifier: TrackerOrderIdentifier, + cartID: String? = nil, + items: [TrackerCartItem] = [], + deliveryPrice: Double? = nil, + total: Double? = nil, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .order, + code: code, + makeData: { + (try TrackerEventDataFactory.order(identifier: identifier, cartID: cartID, items: items, deliveryPrice: deliveryPrice, total: total), nil) + }, + completion: completion + ) + } + + /// Records a subscription change, including its active state. + /// + /// - Parameters: + /// - subscription: Subscription and its active state. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackSubscription( + _ subscription: TrackerSubscription, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .subscription, + code: code, + makeData: { + (try TrackerEventDataFactory.subscription(subscription), nil) + }, + completion: completion + ) + } + + /// Records the start of filling a form identified by `formSelector`. + /// + /// - Parameters: + /// - formSelector: Identifier of the form in the application. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackFormStart( + formSelector: String, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .formStart, + code: code, + makeData: { + (try TrackerEventDataFactory.form(formSelector), nil) + }, + completion: completion + ) + } + + /// Records submission of a form identified by `formSelector`. + /// + /// - Parameters: + /// - formSelector: Identifier of the form in the application. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackFormSubmit( + formSelector: String, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .formSubmit, + code: code, + makeData: { + (try TrackerEventDataFactory.form(formSelector), nil) + }, + completion: completion + ) + } + + /// Records a file download and its optional source element. + /// + /// - Parameters: + /// - fileName: Name of the downloaded file. + /// - element: Optional identifier of the element that initiated the download. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackFileDownload( + fileName: String, + element: String? = nil, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .fileDownload, + code: code, + makeData: { + (try TrackerEventDataFactory.fileDownload(fileName: fileName, element: element), nil) + }, + completion: completion + ) + } + + /// Records adding or removing an offer from favorites. + /// + /// - Parameters: + /// - offer: Offer identifier associated with the event. + /// - removed: Whether the offer was removed from favorites. + /// - code: Optional event code configured for the iOS platform. + /// - completion: Receives the accepted event ID or a tracker error on the main thread. + func trackFavoriteProduct( + offer: TrackerOfferIdentifier, + removed: Bool = false, + code: String? = nil, + completion: @escaping (Result) -> Void = { _ in } + ) { + enqueueTrack( + .favoriteProduct, + code: code, + makeData: { + (try TrackerEventDataFactory.favoriteProduct(offer: offer, removed: removed), nil) + }, + completion: completion + ) + } + + /// Attempts all events in the current queue snapshot without bypassing consent or rate limits. + /// The completion runs once on the main actor; partial delivery reports remaining IDs in `.flushIncomplete`. + /// + /// - Parameters: + /// - completion: Receives the flush result on the main actor; partial delivery reports remaining event IDs. + func flushAll( + completion: @escaping (Result) -> Void = { _ in } + ) { + eventRecordingQueue.async { [self] in + let id = UUID() + stateLock.withLock { + let snapshot = eventQueue.snapshot() + flushAllProgress[id] = TrackerFlushAllProgress(eventIDs: Set(snapshot.map(\.id))) + let previousTask = operationTask + let task = Task { [weak self] in + await previousTask?.value + let result: Result + if let self { + result = await self.performFlushAll(snapshot: snapshot, requestID: id) + } else { + result = .failure(.unavailable) + } + self?.stateLock.withLock { + self?.flushAllProgress.removeValue(forKey: id) + self?.flushAllTasks.removeValue(forKey: id) + } + await MainActor.run { completion(result) } + } + flushAllTasks[id] = task + operationTask = task + } + } + } +} + +package extension Tracker { + + /// Sends one pending batch during automatic queue processing. + @discardableResult + func flush() async -> Result { + let id = UUID() + let task = stateLock.withLock { () -> Task, Never> in + let previousTask = operationTask + let task = Task, Never> { [weak self] in + await previousTask?.value + guard let self, !Task.isCancelled else { return .failure(TrackerError.unavailable) } + return await self.performFlush() + } + operationTask = Task { _ = await task.value } + flushTasks[id] = task + return task + } + let result = await task.value + _ = stateLock.withLock { flushTasks.removeValue(forKey: id) } + return result + } +} + +private struct TrackerFlushBatchOutcome { + let failedIDs: Set + let error: TrackerError? + let blocked: Bool + + static func blocked(_ events: [TrackerEvent], error: TrackerError) -> Self { + Self(failedIDs: Set(events.map(\.id)), error: error, blocked: true) + } +} + +private struct TrackerFlushAllProgress { + let eventIDs: Set + var deliveredIDs: Set = [] + var terminalIDs: Set = [] +} + +private extension Tracker { + + func performFlush() async -> Result { + guard stateLock.withLock({ acceptsEvents }) else { + return .failure(storageInitializationError ?? .missingConnection) + } + guard canCollectEvents else { + return .failure(requiresConsent && consentState != .granted ? .consentRequired : .unavailable) + } + guard !isRateLimited else { + return .failure(.httpStatus(429, [], retryAfter: rateLimitedUntil?.timeIntervalSinceNow)) + } + + await refreshTimeSyncIfNeeded() + guard !isRateLimited else { + return .failure(.httpStatus(429, [], retryAfter: rateLimitedUntil?.timeIntervalSinceNow)) + } + + let (events, generation, userToken): ([TrackerEvent], UInt64, String?) = stateLock.withLock { + guard acceptsEvents, canCollectEventsForCurrentSession else { + return ([], collectionGeneration, nil) + } + return ( + Array(eventQueue.snapshot().prefix(effectiveBatchSize)), + collectionGeneration, + activeUserToken + ) + } + guard !events.isEmpty else { + return .success(()) + } + guard let userToken else { return .failure(.missingConnection) } + let outcome = await sendBatch(events, generation: generation, userToken: userToken) + guard !outcome.failedIDs.isEmpty else { return .success(()) } + return .failure(outcome.error ?? .flushIncomplete(outcome.failedIDs.sorted())) + } + + func performFlushAll( + snapshot: [TrackerEvent], + requestID: UUID + ) async -> Result { + let (ready, userToken, initializationError) = stateLock.withLock { + (acceptsEvents && canCollectEventsForCurrentSession, activeUserToken, storageInitializationError) + } + guard ready, userToken != nil, !Task.isCancelled else { + if requiresConsent && consentState != .granted { return .failure(.consentRequired) } + return .failure(initializationError ?? (userToken == nil ? .missingConnection : .unavailable)) + } + guard !isRateLimited else { + return .failure(.httpStatus(429, [], retryAfter: rateLimitedUntil?.timeIntervalSinceNow)) + } + + let oversizedIDs: [String] + do { + oversizedIDs = try stateLock.withLock { + try storage?.loadOversizedEventIDs() ?? [] + } + } catch { + return .failure(.storage(error.localizedDescription)) + } + var offset = 0 + + while offset < snapshot.count { + let (generation, userToken, batchSize, ready) = stateLock.withLock { + (collectionGeneration, activeUserToken, effectiveBatchSize, acceptsEvents && canCollectEventsForCurrentSession) + } + guard ready, let userToken, !Task.isCancelled else { + break + } + if isRateLimited { + break + } + + let end = min(snapshot.count, offset + max(1, batchSize)) + let batch = Array(snapshot[offset.. TrackerFlushBatchOutcome { + guard !events.isEmpty else { + return TrackerFlushBatchOutcome(failedIDs: [], error: nil, blocked: false) + } + guard isCurrentCollection(generation, userToken: userToken), !Task.isCancelled else { + return .blocked(events, error: .unavailable) + } + guard !isRateLimited else { + return .blocked( + events, + error: .httpStatus(429, [], retryAfter: rateLimitedUntil?.timeIntervalSinceNow) + ) + } + + let customer = trackerStateStore.currentState + let outgoing = events.map { + $0.attributedIfAnonymous( + systemCustomerID: customer.systemCustomerID, + systemCustomerExternalID: customer.siteCustomerID + ) + } + do { + let batches = try TrackerBatchBuilder.batches(outgoing) + if batches.count > 1 { + return await sendBatches(batches, generation: generation, userToken: userToken) + } + if events.count == 1 { + let size = try JSONEncoder().encode(outgoing).count + if size > TrackerBatchBuilder.requestLimit { + return quarantineOversizedEvent( + events[0], error: .eventTooLarge(sizeBytes: size, limitBytes: TrackerBatchBuilder.requestLimit), + generation: generation, userToken: userToken + ) + } + } + } catch { + let failure = TrackerError.requestEncodingFailed(error.localizedDescription) + publish(.retryScheduled(events.map(\.id), failure)) + return TrackerFlushBatchOutcome(failedIDs: Set(events.map(\.id)), error: failure, blocked: false) + } + + let batchIDs = events.map(\.id) + do { + let response = try await client.send(events: outgoing, expectedUserToken: userToken) { [self] payload in + publish(.sending(batchIDs, payload: payload)) + } + guard isCurrentCollection(generation, userToken: userToken), !Task.isCancelled else { + return .blocked(events, error: .unavailable) + } + let attemptedIDs = Set(batchIDs) + let failures = response.failures(attemptedEventIDs: attemptedIDs) + let rejectedIDs = Set(failures.rejectedReasons.keys) + let deliveredIDs = attemptedIDs.subtracting(failures.retryableIDs).subtracting(rejectedIDs) + do { + try removePersistedEvents(ids: deliveredIDs.union(rejectedIDs)) + } catch { + let failure = TrackerError.storage(error.localizedDescription) + publish(.retryScheduled(batchIDs, failure)) + return TrackerFlushBatchOutcome(failedIDs: attemptedIDs, error: failure, blocked: false) + } + if !deliveredIDs.isEmpty { + publish(.delivered(batchIDs.filter(deliveredIDs.contains))) + } + for event in events { + if let reason = failures.rejectedReasons[event.id] { + publish(.rejected(event.id, .eventRejected(reason))) + } + } + if !failures.retryableIDs.isEmpty { + publish(.retryScheduled( + batchIDs.filter(failures.retryableIDs.contains), + .requestFailed(TrackerLocalization.serverRetry.localized) + )) + } + let failedIDs = failures.retryableIDs.union(rejectedIDs) + return TrackerFlushBatchOutcome( + failedIDs: failedIDs, + error: failedIDs.isEmpty ? nil : .flushIncomplete(failedIDs.sorted()), + blocked: false + ) + } catch let error as TrackerClientError { + let trackerError = error.asTrackerError + if case .httpStatus(413, _, _) = trackerError { + // The server may enforce a lower limit; isolate whole events without guessing it. + if events.count > 1 { + return await sendBatches(events.map { [$0] }, generation: generation, userToken: userToken) + } + return quarantineOversizedEvent( + events[0], error: .eventRejected(trackerError.localizedDescription), + generation: generation, userToken: userToken + ) + } + applyRateLimit(error: error) + publish(.retryScheduled(batchIDs, trackerError)) + let blocked: Bool + if case let .httpStatus(status, _, _) = trackerError { + blocked = status == 401 || status == 403 || status == 429 + } else { + blocked = false + } + return TrackerFlushBatchOutcome(failedIDs: Set(batchIDs), error: trackerError, blocked: blocked) + } catch let error as TrackerError { + publish(.retryScheduled(batchIDs, error)) + let blocked: Bool + if case .missingConnection = error { + blocked = true + } else { + blocked = false + } + return TrackerFlushBatchOutcome(failedIDs: Set(batchIDs), error: error, blocked: blocked) + } catch is CancellationError { + return .blocked(events, error: .unavailable) + } catch let error as URLError where error.code == .notConnectedToInternet || error.code == .networkConnectionLost { + let trackerError = TrackerError.requestFailed(error.localizedDescription) + publish(.retryScheduled(batchIDs, trackerError)) + return .blocked(events, error: trackerError) + } catch { + let trackerError = TrackerError.requestFailed(error.localizedDescription) + publish(.retryScheduled(batchIDs, trackerError)) + return TrackerFlushBatchOutcome(failedIDs: Set(batchIDs), error: trackerError, blocked: false) + } + } + + func sendBatches( + _ batches: [[TrackerEvent]], + generation: UInt64, + userToken: String + ) async -> TrackerFlushBatchOutcome { + var failedIDs = Set() + var firstError: TrackerError? + for (index, batch) in batches.enumerated() { + let outcome = await sendBatch(batch, generation: generation, userToken: userToken) + failedIDs.formUnion(outcome.failedIDs) + firstError = firstError ?? outcome.error + if outcome.blocked { + failedIDs.formUnion(batches.dropFirst(index + 1).flatMap { $0.map(\.id) }) + return TrackerFlushBatchOutcome(failedIDs: failedIDs, error: firstError, blocked: true) + } + } + return TrackerFlushBatchOutcome(failedIDs: failedIDs, error: firstError, blocked: false) + } + + func quarantineOversizedEvent( + _ event: TrackerEvent, + error failure: TrackerError, + generation: UInt64, + userToken: String + ) -> TrackerFlushBatchOutcome { + do { + try stateLock.withLock { + guard collectionGeneration == generation, + acceptsEvents, + activeUserToken == userToken, + canCollectEventsForCurrentSession, + let storage else { + throw TrackerError.unavailable + } + try storage.moveToOversized(event) + eventQueue.remove(ids: [event.id]) + } + publish(.rejected(event.id, failure)) + return TrackerFlushBatchOutcome(failedIDs: [event.id], error: failure, blocked: false) + } catch { + let trackerError = (error as? TrackerError) ?? .storage(error.localizedDescription) + publish(.retryScheduled([event.id], trackerError)) + return TrackerFlushBatchOutcome( + failedIDs: [event.id], + error: trackerError, + blocked: trackerError == .unavailable + ) + } + } + + func enqueueInitialization(userToken: String) -> Task, Never> { + stateLock.withLock { + storageInitializationError = nil + let previousTask = operationTask + initializationGeneration &+= 1 + let generation = initializationGeneration + let task = Task { [weak self] () -> Result in + await previousTask?.value + guard let self else { return .failure(.unavailable) } + return self.eventRecordingQueue.sync { + self.performStart(userToken: userToken, generation: generation) + } + } + operationTask = Task { [weak self] in + guard case .success = await task.value, let self else { return } + self.startPeriodicFlushIfNeeded() + _ = await self.performFlush() + } + return task + } + } + + func performStart(userToken: String, generation: UInt64) -> Result { + if stateLock.withLock({ storage == nil }) { + do { + let newStorage = try SQLiteTrackerStorage() + stateLock.withLock { + storage = newStorage + storageInitializationError = nil + } + } catch { + stateLock.withLock { + storageInitializationError = .storage(error.localizedDescription) + } + } + } + var startupDropped: [TrackerEvent] = [] + let error = stateLock.withLock { () -> TrackerError? in + guard initializationGeneration == generation else { return .unavailable } + if !canCollectEventsForCurrentSession { + startupDropped = startupEvents + startupEvents.removeAll() + canBufferStartupEvents = false + currentPageContext = nil + } + do { + if needsStorageReset { + guard let storage else { + throw storageInitializationError ?? .storage(TrackerLocalization.storageUnavailable.localized) + } + try storage.clearForIdentityChange() + needsStorageReset = false + } + if try activateStorageScope(userToken: userToken) { + rateLimitedUntil = nil + effectiveBatchSize = min(config.batchSize, 50) + } + if requiresConsent && consentState != .granted || consentState == .denied { + guard let storage else { + throw storageInitializationError ?? .storage(TrackerLocalization.storageUnavailable.localized) + } + try storage.savePendingEvents([]) + try storage.clearOversizedEvents() + eventQueue.clear() + } else { + try restorePendingEventsLocked() + } + if !startupEvents.isEmpty { + guard let storage else { + throw storageInitializationError ?? .storage(TrackerLocalization.storageUnavailable.localized) + } + let customer = trackerStateStore.currentState + let buffered = startupEvents.map { + $0.attributedIfAnonymous( + systemCustomerID: customer.systemCustomerID, + systemCustomerExternalID: customer.siteCustomerID + ) + } + let combined = eventQueue.snapshot() + buffered + let retained = Array(combined.suffix(Self.startupEventLimit)) + try storage.savePendingEvents(retained) + startupDropped = Array(combined.dropLast(retained.count)) + eventQueue.clear() + eventQueue.prepend(retained) + } + startupEvents.removeAll() + canBufferStartupEvents = false + } catch { + acceptsEvents = false + storageInitializationError = error as? TrackerError ?? .storage(error.localizedDescription) + return storageInitializationError + } + storageInitializationError = nil + acceptsEvents = true + activeUserToken = userToken + return nil + } + if !startupDropped.isEmpty { + publish(.dropped(startupDropped.map(\.id), canCollectEvents + ? TrackerLocalization.queueCapacityExceeded.localized + : TrackerLocalization.trackingConsentUnavailable.localized)) + } + if let error { + publish(.retryScheduled([], error)) + publishReadiness(error: error, generation: generation) + return .failure(error) + } + publishReadiness(generation: generation) + return .success(()) + } + + func performDisconnect() { + stateLock.withLock { + eventQueue.clear() + activeUserToken = nil + currentPageContext = nil + rateLimitedUntil = nil + } + } + + func activateStorageScope(userToken: String) throws -> Bool { + guard !userToken.isEmpty else { + return false + } + return try storage?.activateScope( + siteToken: sessionRuntime.config.siteToken, + userToken: userToken + ) ?? false + } + + func restorePendingEventsLocked() throws { + guard let storage else { throw storageInitializationError ?? .storage(TrackerLocalization.storageUnavailable.localized) } + let events = try storage.loadPendingEvents() + eventQueue.clear() + let dropped = eventQueue.prepend(events) + if !dropped.isEmpty { + try storage.savePendingEvents(eventQueue.snapshot()) + } + } + + func applyRateLimit(error: TrackerClientError) { + guard let retryAfter = error.rateLimitRetryAfter, retryAfter > 0 else { + return + } + + let until = Date().addingTimeInterval(retryAfter) + guard let currentRateLimitedUntil = rateLimitedUntil else { + rateLimitedUntil = until + return + } + + if currentRateLimitedUntil < until { + rateLimitedUntil = until + } + } + + func enqueueTrack( + _ type: TrackerEventName, + code: String?, + createCustomer: Bool? = nil, + createCustomerByEmail: Bool? = nil, + makeData: @escaping () throws -> ([String: TrackerEventDataValue]?, TrackerPageContext?), + completion: @escaping (Result) -> Void + ) { + let utm = type == .order ? CrmUtmAttributionStorage.shared.load() : nil + enqueueRecord({ [self] in + record( + type, + code: code, + createCustomer: createCustomer, + createCustomerByEmail: createCustomerByEmail, + utm: utm, + makeData: makeData + ) + }, completion: completion) + } + + func enqueueRecord( + _ operation: @escaping () -> Result, + completion: @escaping (Result) -> Void + ) { + eventRecordingQueue.async { + let result = operation() + DispatchQueue.main.async { completion(result) } + } + } + + func record( + _ type: TrackerEventName, + code: String?, + createCustomer: Bool? = nil, + createCustomerByEmail: Bool? = nil, + utm: CrmUtmAttribution? = nil, + makeData: () throws -> ([String: TrackerEventDataValue]?, TrackerPageContext?) + ) -> Result { + do { + let normalizedCode = try TrackerEventDataFactory.code(code) + let (data, explicitContext) = try makeData() + return enqueueEvent( + type: type, + data: data, + code: normalizedCode, + createCustomer: createCustomer, + createCustomerByEmail: createCustomerByEmail, + utm: utm, + pageContext: explicitContext + ) + } catch let error as TrackerError { + return .failure(error) + } catch { + return .failure(.invalidEvent(error.localizedDescription)) + } + } + + func enqueueEvent( + type: TrackerEventName, + data: [String: TrackerEventDataValue]?, + code: String?, + createCustomer: Bool?, + createCustomerByEmail: Bool?, + utm: CrmUtmAttribution? = nil, + pageContext: TrackerPageContext? + ) -> Result { + let context = pageContext ?? stateLock.withLock { currentPageContext } + let customer = trackerStateStore.currentState + let event = TrackerEvent( + type: type.rawValue, + code: code, + data: data, + pageURL: context?.pageURL, + pageTitle: context?.pageTitle, + referrerURL: context?.referrerURL, + timestamp: TrackerEventTimestamp.make(trackerTimeSyncStore.resolvedServerDate()), + createCustomer: createCustomer, + createCustomerByEmail: createCustomerByEmail, + systemCustomerID: customer.systemCustomerID, + systemCustomerExternalID: customer.siteCustomerID, + utm: utm + ) + + let outcome = stateLock.withLock { () -> Result<([TrackerEvent], Bool), TrackerError> in + guard acceptsEvents else { + guard consentState != .denied else { return .failure(.consentRequired) } + guard canBufferStartupEvents else { return .failure(.missingConnection) } + startupEvents.append(event) + let overflow = max(0, startupEvents.count - Self.startupEventLimit) + let dropped = Array(startupEvents.prefix(overflow)) + startupEvents.removeFirst(overflow) + if let pageContext { currentPageContext = pageContext } + return .success((dropped, false)) + } + guard canCollectEventsForCurrentSession else { + return .failure(requiresConsent && consentState != .granted ? .consentRequired : .unavailable) + } + guard let storage else { + return .failure(storageInitializationError ?? .storage(TrackerLocalization.storageUnavailable.localized)) + } + let previousEvents = eventQueue.snapshot() + let dropped = eventQueue.append(event) + do { + try storage.appendPendingEvent(event, dropping: Set(dropped.map(\.id))) + } catch { + eventQueue.clear() + eventQueue.prepend(previousEvents) + return .failure(.storage(error.localizedDescription)) + } + if let pageContext { currentPageContext = pageContext } + return .success((dropped, eventQueue.count >= effectiveBatchSize)) + } + switch outcome { + case let .failure(error): + return .failure(error) + case let .success((dropped, shouldFlush)): + publish(.queued([event.id])) + if !dropped.isEmpty { + publish(.dropped(dropped.map(\.id), TrackerLocalization.queueCapacityExceeded.localized)) + } + if shouldFlush { + Task { [weak self] in + _ = await self?.flush() + } + } + return .success(event.id) + } + } + + func publish(_ update: TrackerEventUpdate) { + let handler = stateLock.withLock { () -> ((TrackerEventUpdate) -> Void)? in + let deliveredIDs: Set + let terminalIDs: Set + switch update { + case let .delivered(ids): + deliveredIDs = Set(ids) + terminalIDs = [] + case let .rejected(id, _): + deliveredIDs = [] + terminalIDs = [id] + case let .dropped(ids, _): + deliveredIDs = [] + terminalIDs = Set(ids) + default: + deliveredIDs = [] + terminalIDs = [] + } + if !deliveredIDs.isEmpty || !terminalIDs.isEmpty { + for id in Array(flushAllProgress.keys) { + guard var progress = flushAllProgress[id] else { continue } + progress.deliveredIDs.formUnion(deliveredIDs.intersection(progress.eventIDs)) + progress.terminalIDs.formUnion(terminalIDs.intersection(progress.eventIDs)) + flushAllProgress[id] = progress + } + } + return eventUpdateHandler + } + handler?(update) + } + + func removePersistedEvents(ids: Set) throws { + try stateLock.withLock { + guard let storage else { throw storageInitializationError ?? .storage(TrackerLocalization.storageUnavailable.localized) } + try storage.removePendingEvents(ids: ids) + eventQueue.remove(ids: ids) + } + } + + @discardableResult + func enqueueOperation( + _ operation: @escaping (Tracker) async -> Void + ) -> Task { + stateLock.withLock { + let previousTask = operationTask + let task = Task { [weak self] in + await previousTask?.value + guard let self, !Task.isCancelled else { + return + } + await operation(self) + } + operationTask = task + return task + } + } + + func pauseEventCollection() { + stateLock.withLock { + readyResult = nil + readyRevision &+= 1 + acceptsEvents = false + activeUserToken = nil + collectionGeneration &+= 1 + flushAllTasks.values.forEach { $0.cancel() } + periodicFlushTask?.cancel() + periodicFlushTask = nil + } + } + + func isCurrentCollection(_ generation: UInt64, userToken: String) -> Bool { + stateLock.withLock { + generation == collectionGeneration && + activeUserToken == userToken && + acceptsEvents && + canCollectEventsForCurrentSession + } + } + + func refreshTimeSyncIfNeeded() async { + guard + !Task.isCancelled, + !isRateLimited, + trackerTimeSyncStore.shouldRefresh(interval: config.timeSyncInterval) + else { + return + } + + do { + let timestamp = try await client.fetchServerTime() + trackerTimeSyncStore.setServerTimestamp(timestamp) + } catch let error as TrackerClientError { + applyRateLimit(error: error) + } catch { + return + } + } + + func startPeriodicFlushIfNeeded() { + stateLock.withLock { + guard + acceptsEvents, + canCollectEventsForCurrentSession, + config.flushInterval > 0, + periodicFlushTask == nil + else { + return + } + + periodicFlushTask = Task { [weak self] in + while !Task.isCancelled { + guard let flushInterval = self?.config.flushInterval else { + return + } + + do { + let nanoseconds = UInt64((flushInterval * 1_000_000_000).rounded()) + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return + } + + await self?.flush() + } + } + } + } +} + +package extension Tracker { + + /// Records a recommendation view using the normal tracker queue. + func trackRecommendationImpression( + executionID: UInt64, + screenURI: String, + title: String?, + completion: @escaping (Result) -> Void + ) { + enqueueRecord( + { [self] in + return record(.pageView, code: nil) { + guard executionID > 0 else { + throw TrackerError.invalidEvent("recommendation execution ID must be greater than zero") + } + let uri = try TrackerEventDataFactory.absoluteURI(screenURI, name: "screenURI") + let previous = stateLock.withLock { currentPageContext } + let context = TrackerPageContext( + pageURL: uri, pageTitle: title, referrerURL: previous?.pageURL + ) + return (["recommendation_execution_id": .uint64(executionID)], context) + } + }, + completion: completion + ) + } + + /// Records a recommendation product click using the normal tracker queue. + func trackRecommendationClick( + executionID: UInt64, + productID: Int64, + position: Int?, + element: String?, + leaving: Bool, + completion: @escaping (Result) -> Void + ) { + enqueueTrack(.click, code: nil, makeData: { + guard executionID > 0, productID > 0, productID <= Int64(UInt32.max) else { + throw TrackerError.invalidEvent("invalid recommendation click identifiers") + } + if let position, !(0...Int(UInt32.max)).contains(position) { + throw TrackerError.invalidEvent("invalid recommendation position") + } + var data: [String: TrackerEventDataValue] = [ + "recommendation_execution_id": .uint64(executionID), + "product_id": .int(Int(productID)), + "leaving": .bool(leaving) + ] + if let position { data["recommendation_position"] = .int(position) } + if let element = element?.trimmingCharacters(in: .whitespacesAndNewlines), !element.isEmpty { + data["el"] = .string(element) + } + return (data, nil) + }, completion: completion) + } + +} + +extension Tracker: SessionLifecycleListener { + + package func sessionDidFailToConnect(_ error: CoreError) { + eventRecordingQueue.sync { + pauseEventCollection() + stateLock.withLock { + initializationGeneration &+= 1 + } + publishReadiness(error: TrackerError.from(error)) + } + } + + package func sessionDidConnect( + credentials: SessionCredentials, + pageContext: ConnectPageContext, + user: ConnectUser, + response: ConnectResponse, + customerIdentity: CrmSdkCustomerIdentity? + ) { + eventRecordingQueue.sync { + handleSessionConnect( + credentials: credentials, pageContext: pageContext, response: response, + customerIdentity: customerIdentity + ) + } + } + + private func handleSessionConnect( + credentials: SessionCredentials, pageContext: ConnectPageContext, + response: ConnectResponse, customerIdentity: CrmSdkCustomerIdentity? + ) { + pauseEventCollection() + let dropped = stateLock.withLock { () -> [TrackerEvent] in + let changedUser = connectedUserToken != nil && connectedUserToken != credentials.userToken + connectedUserToken = credentials.userToken + guard changedUser else { return [] } + let events = startupEvents + startupEvents.removeAll() + canBufferStartupEvents = false + currentPageContext = nil + return events + } + if !dropped.isEmpty { + publish(.dropped(dropped.map(\.id), TrackerLocalization.trackingConsentUnavailable.localized)) + } + trackerStateStore.setSystemCustomerID(customerIdentity?.customerId) + trackerStateStore.setSiteCustomerID(customerIdentity?.siteCustomerId) + trackerStateStore.setAvailability( + response.behaviour?.tracker == true, + requiresConsent: response.behaviour?.consentForm == true + ) + trackerTimeSyncStore.setServerTimestamp(response.timestamp) + stateLock.withLock { + if canBufferStartupEvents, currentPageContext != nil { return } + currentPageContext = pageContext.url.flatMap { url in + guard url.scheme != nil else { return nil } + return TrackerPageContext( + pageURL: url.absoluteString, + pageTitle: pageContext.title, + referrerURL: pageContext.referURL?.scheme == nil + ? nil : pageContext.referURL?.absoluteString + ) + } + } + _ = enqueueInitialization(userToken: credentials.userToken) + } + + package func sessionDidDisconnect() { + eventRecordingQueue.sync { handleSessionDisconnect() } + } + + private func handleSessionDisconnect() { + pauseEventCollection() + let resetError = stateLock.withLock { () -> TrackerError? in + needsStorageReset = true + do { + if let storage { + try storage.clearForIdentityChange() + needsStorageReset = false + } + return nil + } catch { + let failure = TrackerError.storage(error.localizedDescription) + storageInitializationError = failure + return failure + } + } + if let resetError { publish(.retryScheduled([], resetError)) } + let dropped = stateLock.withLock { () -> [TrackerEvent] in + let events = eventQueue.snapshot() + startupEvents + eventQueue.clear() + startupEvents.removeAll() + canBufferStartupEvents = false + currentPageContext = nil + return events + } + if !dropped.isEmpty { + publish(.dropped(dropped.map(\.id), TrackerLocalization.trackingConsentUnavailable.localized)) + } + stateLock.withLock { + initializationGeneration &+= 1 + } + trackerStateStore.setSystemCustomerID(nil) + trackerStateStore.setSiteCustomerID(nil) + trackerStateStore.setAvailability(false, requiresConsent: false) + trackerTimeSyncStore.clear() + if let resetError { publishReadiness(error: resetError) } + enqueueOperation { tracker in + tracker.performDisconnect() + } + } +} diff --git a/Sources/Tracker/TrackerClient.swift b/Sources/Tracker/TrackerClient.swift new file mode 100644 index 0000000..15cab97 --- /dev/null +++ b/Sources/Tracker/TrackerClient.swift @@ -0,0 +1,127 @@ +import Core +import Foundation + +package final class TrackerClient { + private let sessionRuntime: SessionRuntime + private let session: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + package init(sessionRuntime: SessionRuntime, session: URLSession = NetworkSession.shared) { + self.sessionRuntime = sessionRuntime + self.session = session + } + + @discardableResult + package func send( + events: [TrackerEvent], + expectedUserToken: String, + onSending: ((String) -> Void)? = nil + ) async throws -> TrackerStoreEventsResponse { + guard !events.isEmpty else { + return TrackerStoreEventsResponse() + } + + guard let credentials = await sessionRuntime.credentials else { + throw TrackerError.missingConnection + } + guard credentials.userToken == expectedUserToken else { + throw TrackerError.missingConnection + } + + let headers: [String: String] + do { + headers = try await sessionRuntime.authorizedHeaders() + } catch { + throw TrackerError.missingConnection + } + guard headers["X-User-Token"] == expectedUserToken else { + throw TrackerError.missingConnection + } + + var request = URLRequest(url: collectorEndpointURL(path: "event", credentials: credentials)) + request.httpMethod = "POST" + for (header, value) in headers { + request.setValue(value, forHTTPHeaderField: header) + } + let body: Data + do { + body = try encoder.encode(events) + } catch { + throw TrackerError.requestEncodingFailed(error.localizedDescription) + } + + request.httpBody = body + onSending?(String(decoding: body, as: UTF8.self)) + let (data, response) = try await NetworkSession.data( + for: request, in: session + ) + try Self.validateResponse(response, data: data) + + guard !data.isEmpty else { + return TrackerStoreEventsResponse() + } + + do { + return try decoder.decode(TrackerStoreEventsResponse.self, from: data) + } catch { + throw TrackerError.invalidResponse(error.localizedDescription) + } + } + + package func fetchServerTime() async throws -> String? { + guard let credentials = await sessionRuntime.credentials else { + throw CoreError.missingConnectionCredentials + } + + var request = URLRequest(url: collectorEndpointURL(path: "ts", credentials: credentials)) + request.httpMethod = "GET" + for (header, value) in try await sessionRuntime.authorizedHeaders(contentType: false) { + request.setValue(value, forHTTPHeaderField: header) + } + + let (data, response) = try await NetworkSession.data( + for: request, in: session + ) + try Self.validateResponse(response, data: data) + + guard !data.isEmpty else { + return nil + } + + do { + return try decoder.decode(TrackerServerTimeResponse.self, from: data).timestamp + } catch { + throw TrackerError.invalidResponse(error.localizedDescription) + } + } + + private static func validateResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw TrackerClientError.httpStatus(0, data, retryAfter: nil) + } + + let statusCode = httpResponse.statusCode + guard (200..<300).contains(statusCode) else { + throw TrackerClientError.httpStatus( + statusCode, + data, + retryAfter: statusCode == 429 + ? TrackerRateLimit.retryAfter(from: httpResponse, data: data) + : nil + ) + } + } + + private func collectorEndpointURL(path: String, credentials: SessionCredentials) -> URL { + var components = URLComponents( + url: sessionRuntime.config.endpoints.clientAPIEndpointURL.appendingPathComponent(path), + resolvingAgainstBaseURL: false + )! + components.queryItems = [ + URLQueryItem(name: "user", value: credentials.userToken), + URLQueryItem(name: "session", value: credentials.sessionID) + ] + return components.url! + } +} diff --git a/Sources/Tracker/TrackerError.swift b/Sources/Tracker/TrackerError.swift new file mode 100644 index 0000000..f47809b --- /dev/null +++ b/Sources/Tracker/TrackerError.swift @@ -0,0 +1,81 @@ +import Core +import Foundation + +/// Errors surfaced by the Tracker module. +public enum TrackerError: Error, Equatable, LocalizedError, Sendable { + /// Event parameters cannot be converted into a valid tracker event. + case invalidEvent(String) + /// The SDK rejected the single-event JSON request before sending it. + case eventTooLarge(sizeBytes: Int, limitBytes: Int) + /// The tracker cannot currently accept events. + case unavailable + /// The site requires tracking consent before events can be queued. + case consentRequired + /// Tracker session credentials are unavailable. + case missingConnection + /// Reading or writing the local event queue failed. + case storage(String) + /// Encoding the outgoing request failed. + case requestEncodingFailed(String) + /// Sending the outgoing request failed. + case requestFailed(String) + /// The collector returned an unreadable response. + case invalidResponse(String) + /// The collector returned a non-success HTTP status and optional retry delay. + case httpStatus(Int, [String], retryAfter: TimeInterval?) + /// The collector rejected one event. + case eventRejected(String) + /// IDs not marked as delivered during `flushAll`, including locally oversized events. + case flushIncomplete([String]) + + /// Localized summary of the error, including available diagnostic details. + public var errorDescription: String? { + switch self { + case let .invalidEvent(message): + return TrackerLocalization.invalidEvent.format(message) + case let .eventTooLarge(sizeBytes, limitBytes): + return TrackerLocalization.eventTooLarge.format(Int64(sizeBytes), Int64(limitBytes)) + case let .storage(message): + return TrackerLocalization.storage.format(message) + case let .requestEncodingFailed(message): + return TrackerLocalization.requestEncodingFailed.format(message) + case let .requestFailed(message): + return TrackerLocalization.requestFailed.format(message) + case let .invalidResponse(message): + return TrackerLocalization.invalidResponse.format(message) + case let .eventRejected(message): + return TrackerLocalization.eventRejected.format(message) + case let .flushIncomplete(eventIDs): + return TrackerLocalization.flushIncomplete.format(eventIDs.count) + case .unavailable: + return TrackerLocalization.unavailable.localized + case .consentRequired: + return TrackerLocalization.consentRequired.localized + case .missingConnection: + return TrackerLocalization.missingConnection.localized + case let .httpStatus(status, messages, retryAfter): + let summary = retryAfter.map { + TrackerLocalization.clientHTTPStatusWithRetry.format(status, $0) + } ?? TrackerLocalization.clientHTTPStatus.format(status) + guard !messages.isEmpty else { return summary } + return summary + " " + TrackerLocalization.serverDetails.format(messages.joined(separator: " ")) + } + } +} + +package extension TrackerError { + static func from(_ error: Error) -> TrackerError { + if let error = error as? TrackerError { return error } + if let error = error as? CoreError { + switch error { + case .missingConnectionCredentials: return .missingConnection + case let .httpStatus(status, messages): return .httpStatus(status, messages, retryAfter: nil) + case let .responseDecodingFailed(message): return .invalidResponse(message) + case let .requestEncodingFailed(message): return .requestEncodingFailed(message) + default: return .requestFailed(error.localizedDescription) + } + } + if error is DecodingError { return .invalidResponse(error.localizedDescription) } + return .requestFailed(error.localizedDescription) + } +} diff --git a/Sources/Tracker/TrackerEvent/TrackerEvent.swift b/Sources/Tracker/TrackerEvent/TrackerEvent.swift new file mode 100644 index 0000000..c75640b --- /dev/null +++ b/Sources/Tracker/TrackerEvent/TrackerEvent.swift @@ -0,0 +1,93 @@ +import Core +import Foundation + +package struct TrackerEvent: Codable, Equatable, Sendable { + package let id: String + package let type: String + package let code: String? + package let data: [String: TrackerEventDataValue]? + package let pageURL: String? + package let pageTitle: String? + package let referrerURL: String? + package let timestamp: String + package let createCustomer: Bool? + package let createCustomerByEmail: Bool? + package let systemCustomerID: Int64? + package let systemCustomerExternalID: String? + package let utm: CrmUtmAttribution? + package let platform: String + + package init( + id: String = UUID().uuidString.lowercased(), + type: String, + code: String? = nil, + data: [String: TrackerEventDataValue]? = nil, + pageURL: String? = nil, + pageTitle: String? = nil, + referrerURL: String? = nil, + timestamp: String = TrackerEventTimestamp.make(), + createCustomer: Bool? = nil, + createCustomerByEmail: Bool? = nil, + systemCustomerID: Int64? = nil, + systemCustomerExternalID: String? = nil, + platform: String = "ios", + utm: CrmUtmAttribution? = nil + ) { + self.id = id + self.type = type + self.code = code?.nilIfEmpty + self.data = data?.isEmpty == true ? nil : data + self.pageURL = pageURL?.nilIfEmpty + self.pageTitle = pageTitle?.nilIfEmpty + self.referrerURL = referrerURL?.nilIfEmpty + self.timestamp = timestamp + self.createCustomer = createCustomer + self.createCustomerByEmail = createCustomerByEmail + self.systemCustomerID = systemCustomerID + self.systemCustomerExternalID = systemCustomerExternalID?.nilIfEmpty + self.platform = platform + self.utm = utm + } + + package func attributedIfAnonymous( + systemCustomerID: Int64?, + systemCustomerExternalID: String? + ) -> TrackerEvent { + guard self.systemCustomerID == nil || self.systemCustomerExternalID == nil else { + return self + } + return TrackerEvent( + id: id, + type: type, + code: code, + data: data, + pageURL: pageURL, + pageTitle: pageTitle, + referrerURL: referrerURL, + timestamp: timestamp, + createCustomer: createCustomer, + createCustomerByEmail: createCustomerByEmail, + systemCustomerID: self.systemCustomerID ?? systemCustomerID, + systemCustomerExternalID: self.systemCustomerExternalID ?? systemCustomerExternalID, + platform: platform, + utm: utm + ) + } + + private enum CodingKeys: String, CodingKey { + case id + case type + case code + case data + case pageURL = "page_url" + case pageTitle = "page_title" + case referrerURL = "referrer_url" + case timestamp + case createCustomer = "create_customer" + case createCustomerByEmail = "create_customer_by_email" + case systemCustomerID = "system_customer_id" + case systemCustomerExternalID = "system_customer_external_id" + case utm + case platform + } +} diff --git a/Sources/Tracker/TrackerEvent/TrackerEventDataValue.swift b/Sources/Tracker/TrackerEvent/TrackerEventDataValue.swift new file mode 100644 index 0000000..7036eda --- /dev/null +++ b/Sources/Tracker/TrackerEvent/TrackerEventDataValue.swift @@ -0,0 +1,62 @@ +import Foundation + +/// JSON value that can be sent inside tracker event `data`. +package indirect enum TrackerEventDataValue: Codable, Equatable, Sendable { + case string(String) + case int(Int) + case uint64(UInt64) + case double(Double) + case bool(Bool) + case object([String: TrackerEventDataValue]) + case array([TrackerEventDataValue]) + case null + + package func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .uint64(value): + try container.encode(value) + case let .double(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + package init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(UInt64.self) { + self = .uint64(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: TrackerEventDataValue].self) { + self = .object(value) + } else if let value = try? container.decode([TrackerEventDataValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported tracker event data value." + ) + } + } +} diff --git a/Sources/Tracker/TrackerEventName.swift b/Sources/Tracker/TrackerEventName.swift new file mode 100644 index 0000000..959c287 --- /dev/null +++ b/Sources/Tracker/TrackerEventName.swift @@ -0,0 +1,23 @@ +// +// TrackerEventName.swift +// ConsultantSdk +// +// Created by Yriy Deviataev on 14.10.2026. +// + +package enum TrackerEventName: String { + case pageView = "page_view" + case click = "click" + case fileDownload = "file_download" + case consult = "consult" + case formStart = "form_start" + case formSubmit = "form_submit" + case login = "login" + case register = "register" + case openCart = "open_cart" + case contact = "contact" + case cart = "cart" + case order = "order" + case subscription = "subscription" + case favoriteProduct = "favorite_product" +} diff --git a/Sources/Tracker/TrackerLocalization.swift b/Sources/Tracker/TrackerLocalization.swift new file mode 100644 index 0000000..2d991e3 --- /dev/null +++ b/Sources/Tracker/TrackerLocalization.swift @@ -0,0 +1,47 @@ +import Foundation + +package enum TrackerLocalization: String { + case clientHTTPStatus = "tracker.error.client_http_status" + case clientHTTPStatusWithRetry = "tracker.error.client_http_status_with_retry" + case invalidEvent = "tracker.error.invalid_event" + case storage = "tracker.error.storage" + case requestEncodingFailed = "tracker.error.request_encoding_failed" + case requestFailed = "tracker.error.request_failed" + case invalidResponse = "tracker.error.invalid_response" + case eventRejected = "tracker.error.event_rejected" + case flushIncomplete = "tracker.error.flush_incomplete" + case serverDetails = "tracker.error.server_details" + case positiveUInt32 = "tracker.error.positive_uint32" + case productIdentityRequired = "tracker.error.product_identity_required" + case customerAttributeRequired = "tracker.error.customer_attribute_required" + case fieldRequired = "tracker.error.field_required" + case absoluteURIRequired = "tracker.error.absolute_uri_required" + case orderSystemIDOutOfRange = "tracker.error.order_system_id_out_of_range" + case offerSystemIDOutOfRange = "tracker.error.offer_system_id_out_of_range" + case identifierTooLong = "tracker.error.identifier_too_long" + case nonNegativeNumber = "tracker.error.non_negative_number" + case eventTooLarge = "tracker.error.event_too_large" + case serverRetry = "tracker.error.server_retry" + case storageUnavailable = "tracker.error.storage_unavailable" + case missingHTTPResponse = "tracker.error.missing_http_response" + case storageScopeInactive = "tracker.error.storage_scope_inactive" + case storageOpenFailed = "tracker.error.storage_open_failed" + case trackingConsentUnavailable = "tracker.update.tracking_consent_unavailable" + case queueCapacityExceeded = "tracker.update.queue_capacity_exceeded" + case sqliteEventPayloadMissing = "tracker.error.sqlite_event_payload_missing" + case unavailable = "tracker.error.unavailable" + case consentRequired = "tracker.error.consent_required" + case missingConnection = "tracker.error.missing_connection" + + package var localized: String { + NSLocalizedString( + rawValue, + bundle: .module, + comment: "" + ) + } + + package func format(_ arguments: CVarArg...) -> String { + String(format: localized, arguments: arguments) + } +} diff --git a/TestHost/AppDelegate.swift b/TestHost/AppDelegate.swift new file mode 100644 index 0000000..fff63aa --- /dev/null +++ b/TestHost/AppDelegate.swift @@ -0,0 +1,17 @@ +import UIKit + +@main +private final class AppDelegate: UIResponder, UIApplicationDelegate { + private var retainedWindow: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + self.retainedWindow = window + return true + } +} diff --git a/TestHost/README.md b/TestHost/README.md new file mode 100644 index 0000000..c0527a5 --- /dev/null +++ b/TestHost/README.md @@ -0,0 +1,19 @@ +# Тесты SDK внутри iOS-приложения + +Проект `SDKTestHost.xcodeproj` запускает все Swift-тесты из `../Tests` внутри минимального приложения. +Файлы тестов подключаются автоматически через синхронизированную группу Xcode. +Приложение требуется для `UNUserNotificationCenter` и тестов автоматической регистрации push. + +Запуск из корня репозитория: + +```sh +xcodebuild test \ + -project sdk/TestHost/SDKTestHost.xcodeproj \ + -scheme SDKHostedTests \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -parallel-testing-enabled NO +``` + +Требуются Xcode 16 или новее и установленный симулятор из параметра `destination`. +Тесты выполняются последовательно, поскольку используют общие настройки Firebase и SDK. +Обычный запуск схемы пакета `ConsultantSdk-Package` сохраняется; 14 сценариев, требующих приложения, в нём пропускаются. diff --git a/TestHost/SDKTestHost.xcodeproj/project.pbxproj b/TestHost/SDKTestHost.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8121b22 --- /dev/null +++ b/TestHost/SDKTestHost.xcodeproj/project.pbxproj @@ -0,0 +1,512 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 02CB27B609B3626593505F2D /* Tracker in Frameworks */ = {isa = PBXBuildFile; productRef = 67B83A8ECAC7A5575B06B0C4 /* Tracker */; }; + 1AADD611B44EF6C81F682CBB /* PushNotificationExtension in Frameworks */ = {isa = PBXBuildFile; productRef = D183B355DAC88B7EB2FB551B /* PushNotificationExtension */; }; + 38FA55A3BC2B84134D4A1886 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F8632565F5F7EAB2794205A /* Foundation.framework */; }; + 543D823AF26974A4D00CF51A /* PushService in Frameworks */ = {isa = PBXBuildFile; productRef = 45ACC1787670061C752C3E32 /* PushService */; }; + 64BDBBAEF49BDFC683A01130 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F8632565F5F7EAB2794205A /* Foundation.framework */; }; + 786719188C23584F20D8702F /* OnlineConsultant in Frameworks */ = {isa = PBXBuildFile; productRef = 1B6C2AAD539A674FDAB6E4BE /* OnlineConsultant */; }; + 80849B6116D0EAAD827024C8 /* Recommendations in Frameworks */ = {isa = PBXBuildFile; productRef = 1DAB5AE6AF685EAD130AB917 /* Recommendations */; }; + 8E563B4B2F4FE9A34C642957 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39387EBDC6CFD3F8128FFAA8 /* AppDelegate.swift */; }; + A4D3D38164EC59CE5E35AD81 /* Core in Frameworks */ = {isa = PBXBuildFile; productRef = D0F4389E886DEE49A71A6EDF /* Core */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + E2C5E4957AF2A1BD45498277 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9A23B1214B9301CE42CCF03A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 29B83E2865883906F37F0866; + remoteInfo = SDKTestHost; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 39387EBDC6CFD3F8128FFAA8 /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 3F8632565F5F7EAB2794205A /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 51C8B1FAA271C1927DDBB8FB /* SDKTestHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SDKTestHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9EAB79354B91B996B8A320F3 /* SDKHostedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SDKHostedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 9D6D2C628BC031B8A31FC4B6 /* ../Tests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = ../Tests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + D3327ECC0B17D564DE51EBD3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 64BDBBAEF49BDFC683A01130 /* Foundation.framework in Frameworks */, + A4D3D38164EC59CE5E35AD81 /* Core in Frameworks */, + 786719188C23584F20D8702F /* OnlineConsultant in Frameworks */, + 543D823AF26974A4D00CF51A /* PushService in Frameworks */, + 1AADD611B44EF6C81F682CBB /* PushNotificationExtension in Frameworks */, + 80849B6116D0EAAD827024C8 /* Recommendations in Frameworks */, + 02CB27B609B3626593505F2D /* Tracker in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA8ED8CB5D97F33EA92EF4BC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 38FA55A3BC2B84134D4A1886 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1E1EF0A52643A2DAF1C2CCDB /* Products */ = { + isa = PBXGroup; + children = ( + 51C8B1FAA271C1927DDBB8FB /* SDKTestHost.app */, + 9EAB79354B91B996B8A320F3 /* SDKHostedTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2F8741963315C5052260AD9C /* iOS */ = { + isa = PBXGroup; + children = ( + 3F8632565F5F7EAB2794205A /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + 6AFCA1FA026AC9B75A29CFDC = { + isa = PBXGroup; + children = ( + 1E1EF0A52643A2DAF1C2CCDB /* Products */, + C6A5F9B7A18A6F967A3CBD08 /* Frameworks */, + 39387EBDC6CFD3F8128FFAA8 /* AppDelegate.swift */, + 9D6D2C628BC031B8A31FC4B6 /* ../Tests */, + ); + sourceTree = ""; + }; + C6A5F9B7A18A6F967A3CBD08 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2F8741963315C5052260AD9C /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 29B83E2865883906F37F0866 /* SDKTestHost */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7E1A2DAECF6D111037218D8C /* Build configuration list for PBXNativeTarget "SDKTestHost" */; + buildPhases = ( + A6231384A66544FD13BBD494 /* Sources */, + EA8ED8CB5D97F33EA92EF4BC /* Frameworks */, + B7EAA747672E82C836FB50DF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SDKTestHost; + productName = SDKTestHost; + productReference = 51C8B1FAA271C1927DDBB8FB /* SDKTestHost.app */; + productType = "com.apple.product-type.application"; + }; + 7BF63EA6B111C1955A5928FC /* SDKHostedTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8633F73E5C34FFBE94C7D715 /* Build configuration list for PBXNativeTarget "SDKHostedTests" */; + buildPhases = ( + 834BAF917E29622104422ADF /* Sources */, + D3327ECC0B17D564DE51EBD3 /* Frameworks */, + C0D4F33C1DA79F68DE3D3DE6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 6768940FEFA3142381C189A9 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 9D6D2C628BC031B8A31FC4B6 /* ../Tests */, + ); + name = SDKHostedTests; + packageProductDependencies = ( + D0F4389E886DEE49A71A6EDF /* Core */, + 1B6C2AAD539A674FDAB6E4BE /* OnlineConsultant */, + 45ACC1787670061C752C3E32 /* PushService */, + D183B355DAC88B7EB2FB551B /* PushNotificationExtension */, + 1DAB5AE6AF685EAD130AB917 /* Recommendations */, + 67B83A8ECAC7A5575B06B0C4 /* Tracker */, + ); + productName = SDKHostedTests; + productReference = 9EAB79354B91B996B8A320F3 /* SDKHostedTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9A23B1214B9301CE42CCF03A /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = C7A95F729C67A41B036E8BE0 /* Build configuration list for PBXProject "SDKTestHost" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 6AFCA1FA026AC9B75A29CFDC; + minimizedProjectReferenceProxies = 0; + packageReferences = ( + F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 100; + productRefGroup = 1E1EF0A52643A2DAF1C2CCDB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 29B83E2865883906F37F0866 /* SDKTestHost */, + 7BF63EA6B111C1955A5928FC /* SDKHostedTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B7EAA747672E82C836FB50DF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C0D4F33C1DA79F68DE3D3DE6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 834BAF917E29622104422ADF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A6231384A66544FD13BBD494 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8E563B4B2F4FE9A34C642957 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 6768940FEFA3142381C189A9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = SDKTestHost; + target = 29B83E2865883906F37F0866 /* SDKTestHost */; + targetProxy = E2C5E4957AF2A1BD45498277 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 16574948308B731C96A164CF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + OTHER_SWIFT_FLAGS = "$(inherited) -package-name sdk"; + PRODUCT_BUNDLE_IDENTIFIER = tech.retailcrm.SDKHostedTests; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SDKTestHost.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SDKTestHost"; + }; + name = Debug; + }; + 7B795819A71CB4A992F4AC7F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 7D640DD0654479F5CB5C0EE2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_FirebaseAppDelegateProxyEnabled = NO; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = tech.retailcrm.SDKTestHost; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C5AED23EC93CB288DF394E58 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + OTHER_SWIFT_FLAGS = "$(inherited) -package-name sdk"; + PRODUCT_BUNDLE_IDENTIFIER = tech.retailcrm.SDKHostedTests; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SDKTestHost.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SDKTestHost"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DA68EA106013E5C3737D917A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + DB8D7A41B38825C257161063 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_FirebaseAppDelegateProxyEnabled = NO; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = tech.retailcrm.SDKTestHost; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7E1A2DAECF6D111037218D8C /* Build configuration list for PBXNativeTarget "SDKTestHost" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DB8D7A41B38825C257161063 /* Release */, + 7D640DD0654479F5CB5C0EE2 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8633F73E5C34FFBE94C7D715 /* Build configuration list for PBXNativeTarget "SDKHostedTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C5AED23EC93CB288DF394E58 /* Release */, + 16574948308B731C96A164CF /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C7A95F729C67A41B036E8BE0 /* Build configuration list for PBXProject "SDKTestHost" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DA68EA106013E5C3737D917A /* Debug */, + 7B795819A71CB4A992F4AC7F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1B6C2AAD539A674FDAB6E4BE /* OnlineConsultant */ = { + isa = XCSwiftPackageProductDependency; + package = F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */; + productName = OnlineConsultant; + }; + 1DAB5AE6AF685EAD130AB917 /* Recommendations */ = { + isa = XCSwiftPackageProductDependency; + package = F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */; + productName = Recommendations; + }; + 45ACC1787670061C752C3E32 /* PushService */ = { + isa = XCSwiftPackageProductDependency; + package = F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */; + productName = PushService; + }; + 67B83A8ECAC7A5575B06B0C4 /* Tracker */ = { + isa = XCSwiftPackageProductDependency; + package = F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */; + productName = Tracker; + }; + D0F4389E886DEE49A71A6EDF /* Core */ = { + isa = XCSwiftPackageProductDependency; + package = F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */; + productName = Core; + }; + D183B355DAC88B7EB2FB551B /* PushNotificationExtension */ = { + isa = XCSwiftPackageProductDependency; + package = F9EAD997E6754EFA0964A50F /* XCLocalSwiftPackageReference ".." */; + productName = PushNotificationExtension; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 9A23B1214B9301CE42CCF03A /* Project object */; +} diff --git a/TestHost/SDKTestHost.xcodeproj/xcshareddata/xcschemes/SDKHostedTests.xcscheme b/TestHost/SDKTestHost.xcodeproj/xcshareddata/xcschemes/SDKHostedTests.xcscheme new file mode 100644 index 0000000..aacf3be --- /dev/null +++ b/TestHost/SDKTestHost.xcodeproj/xcshareddata/xcschemes/SDKHostedTests.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/ConsultantSdkTests/APIEndpointsTests.swift b/Tests/ConsultantSdkTests/APIEndpointsTests.swift new file mode 100644 index 0000000..36e525e --- /dev/null +++ b/Tests/ConsultantSdkTests/APIEndpointsTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import Core + +final class APIEndpointsTests: XCTestCase { + func testDefaultEndpoints() { + let config = CrmSdkConfig(siteToken: "site") + let endpoints = SessionConfig(siteToken: config.siteToken, baseURL: config.baseURL).endpoints + XCTAssertEqual(config.baseURL, "https://c.retailcrm.tech") + XCTAssertEqual(endpoints.clientAPIEndpointURL.absoluteString, "https://c.retailcrm.tech/api/client/v1") + XCTAssertEqual(endpoints.clientWebSocketEndpointURL.absoluteString, "wss://c.retailcrm.tech/api/client/v1/ws") + } + + func testCustomURLNormalization() { + let cases: [(baseURL: String, http: String, socket: String)] = [ + ("https://consultant.example.com", "https://consultant.example.com/api/client/v1", "wss://consultant.example.com/api/client/v1/ws"), + (" https://consultant.example.com///\n", "https://consultant.example.com/api/client/v1", "wss://consultant.example.com/api/client/v1/ws"), + ("https://consultant.example.com/api/client/v1", "https://consultant.example.com/api/client/v1", "wss://consultant.example.com/api/client/v1/ws"), + ("https://consultant.example.com/api/client/v1///", "https://consultant.example.com/api/client/v1", "wss://consultant.example.com/api/client/v1/ws"), + ("http://localhost:8080/consultant/", "http://localhost:8080/consultant/api/client/v1", "ws://localhost:8080/consultant/api/client/v1/ws") + ] + for entry in cases { + let endpoints = APIEndpoints(baseURL: entry.baseURL) + XCTAssertEqual(endpoints.clientAPIEndpointURL.absoluteString, entry.http, entry.baseURL) + XCTAssertEqual(endpoints.clientWebSocketEndpointURL.absoluteString, entry.socket, entry.baseURL) + } + } + +} diff --git a/Tests/ConsultantSdkTests/ConsultantSdkTests.swift b/Tests/ConsultantSdkTests/ConsultantSdkTests.swift new file mode 100644 index 0000000..7713a85 --- /dev/null +++ b/Tests/ConsultantSdkTests/ConsultantSdkTests.swift @@ -0,0 +1,148 @@ +import Foundation +import XCTest +@testable import Core + +final class ConsultantSdkTests: XCTestCase { + override func setUp() { + super.setUp() + ConnectURLProtocol.reset() + } + + func testSessionRuntimeConnectsAndStoresCredentials() async throws { + ConnectURLProtocol.reset(responseBody: """ + {"user":{"token":"user-token"},"session":{"id":"session-id"}} + """) + let storage = MemoryUserTokenStorage() + let runtime = makeRuntime(storage: storage) + + let credentials = try await runtime.start() + let request = try XCTUnwrap(ConnectURLProtocol.capturedRequest()) + let body = try XCTUnwrap( + JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any] + ) + + XCTAssertEqual(credentials.userToken, "user-token") + XCTAssertEqual(credentials.sessionID, "session-id") + XCTAssertEqual(storage.token, "user-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Site-Token"), "site-token") + XCTAssertEqual(body["page_title"] as? String, "Sample Shop") + } + + func testSessionRuntimeRejectsMissingCredentials() async { + ConnectURLProtocol.reset(responseBody: "{\"user\":{},\"session\":{}}") + let runtime = makeRuntime(storage: MemoryUserTokenStorage()) + + do { + _ = try await runtime.start() + XCTFail("Expected missing connection credentials") + } catch { + XCTAssertEqual(error as? CoreError, .missingConnectionCredentials) + } + } + + func testSessionRuntimeConnectsToCustomBackend() async throws { + ConnectURLProtocol.reset(responseBody: """ + {"user":{"token":"user-token"},"session":{"id":"session-id"}} + """) + let runtime = makeRuntime(storage: MemoryUserTokenStorage(), baseURL: "http://localhost:8080/proxy/") + _ = try await runtime.start() + XCTAssertEqual(ConnectURLProtocol.capturedRequest()?.url?.absoluteString, + "http://localhost:8080/proxy/api/client/v1/connect") + } + + private func makeRuntime( + storage: MemoryUserTokenStorage, baseURL: String = CrmSdkConfig.defaultBaseURL + ) -> SessionRuntime { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ConnectURLProtocol.self] + return SessionRuntime( + config: SessionConfig(siteToken: "site-token", baseURL: baseURL), + pageContext: ConnectPageContext(title: "Sample Shop"), + session: URLSession(configuration: configuration), + userTokenStorage: storage + ) + } +} + +private final class MemoryUserTokenStorage: UserTokenStorage { + private(set) var token: String? + + func getUserToken( + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) throws -> String? { + token + } + + func setUserToken( + _ token: String?, + siteToken: String, + customerIdentity: CrmSdkCustomerIdentity? + ) throws { + self.token = token + } +} + +private final class ConnectURLProtocol: URLProtocol { + private static let lock = NSLock() + private static var responseBody = "{}" + private static var requestSnapshot: URLRequest? + + static func reset(responseBody: String = "{}") { + lock.lock() + defer { lock.unlock() } + self.responseBody = responseBody + requestSnapshot = nil + } + + static func capturedRequest() -> URLRequest? { + lock.lock() + defer { lock.unlock() } + return requestSnapshot + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.path.hasSuffix("/api/client/v1/connect") == true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + var capturedRequest = request + capturedRequest.httpBody = request.httpBody ?? data(from: request.httpBodyStream) + let body: String + Self.lock.lock() + Self.requestSnapshot = capturedRequest + body = Self.responseBody + Self.lock.unlock() + + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private func data(from stream: InputStream?) -> Data? { + guard let stream else { return nil } + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data +} diff --git a/Tests/ConsultantSdkTests/NetworkObserverTests.swift b/Tests/ConsultantSdkTests/NetworkObserverTests.swift new file mode 100644 index 0000000..c5e3b95 --- /dev/null +++ b/Tests/ConsultantSdkTests/NetworkObserverTests.swift @@ -0,0 +1,196 @@ +import Foundation +import XCTest +import Core + +final class NetworkObserverTests: XCTestCase { + override func tearDown() { + NetworkSession.configure(requestTimeout: 30, resourceTimeout: 60) + super.tearDown() + } + + func testConfigurationDefaultsAndObserver() async { + XCTAssertNil(CrmSdkConfig(siteToken: "site").networkObserver) + let observer = RecordingNetworkObserver(completed: expectation(description: "unused")) + observer.completed.isInverted = true + let config = CrmSdkConfig(siteToken: "site", networkObserver: observer) + XCTAssertTrue(config.networkObserver as? RecordingNetworkObserver === observer) + XCTAssertFalse(observer.collectsMetrics) + await fulfillment(of: [observer.completed], timeout: 0.01) + } + + func testHTTPEventsContainPreparedRequestBodyResponseAndUniqueIDs() async throws { + let completed = expectation(description: "Three completed requests") + completed.expectedFulfillmentCount = 3 + let observer = RecordingNetworkObserver(completed: completed) + NetworkSession.configure(requestTimeout: 17, resourceTimeout: 30, observer: observer) + let session = makeSession() + defer { session.invalidateAndCancel() } + for path in ["ok", "bad", "ok"] { + var request = URLRequest(url: URL(string: "https://observer.test/\(path)")!) + request.httpMethod = "POST" + request.httpBody = Data("request-body".utf8) + request.setValue("test-header", forHTTPHeaderField: "X-Test") + _ = try await NetworkSession.data(for: request, in: session) + } + await fulfillment(of: [completed], timeout: 3) + let starts = observer.starts + let finishes = observer.finishes + XCTAssertEqual(Set(starts.map(\.id)).count, 3) + XCTAssertEqual(starts.map(\.id), finishes.map(\.id)) + XCTAssertEqual(observer.eventOrder, ["start", "complete", "start", "complete", "start", "complete"]) + XCTAssertEqual(finishes.map { ($0.response as? HTTPURLResponse)?.statusCode }, [200, 400, 200]) + for start in starts { + XCTAssertEqual(start.request.httpMethod, "POST") + XCTAssertEqual(start.request.timeoutInterval, 17) + XCTAssertEqual(start.request.httpBody, Data("request-body".utf8)) + XCTAssertEqual(start.request.value(forHTTPHeaderField: "X-Test"), "test-header") + XCTAssertLessThan(abs(start.startedAt.timeIntervalSinceNow), 10) + } + for finish in finishes { + XCTAssertEqual(finish.responseBody, Data("response-body".utf8)) + XCTAssertNil(finish.error) + XCTAssertGreaterThanOrEqual(finish.duration, 0) + } + XCTAssertTrue(observer.metricsIDs.isEmpty) + } + + func testFailureTimeoutAndCancellationEmitTheSameErrorsAsTheCaller() async throws { + let completed = expectation(description: "Three failed requests") + completed.expectedFulfillmentCount = 3 + let observer = RecordingNetworkObserver(completed: completed) + let session = makeSession() + defer { session.invalidateAndCancel() } + for (path, code) in [("offline", URLError.notConnectedToInternet), ("slow-timeout", .timedOut), ("slow-cancel", .cancelled)] { + NetworkSession.configure(requestTimeout: 10, resourceTimeout: path == "slow-timeout" ? 0.05 : 30, observer: observer) + let task = Task { + try await NetworkSession.data(for: URLRequest(url: URL(string: "https://observer.test/\(path)")!), in: session) + } + if code == .cancelled { + try await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + } + do { + _ = try await task.value + XCTFail("Expected \(code)") + } catch { + XCTAssertEqual((error as? URLError)?.code, code) + } + } + await fulfillment(of: [completed], timeout: 3) + XCTAssertEqual(observer.finishes.map { ($0.error as? URLError)?.code }, [.notConnectedToInternet, .timedOut, .cancelled]) + XCTAssertEqual(observer.starts.map(\.id), observer.finishes.map(\.id)) + XCTAssertTrue(observer.finishes.allSatisfy { $0.response == nil }) + } + + func testMetricsAreCorrelatedAndSessionDelegateStillReceivesThem() async throws { + let completed = expectation(description: "Completed request") + let metrics = expectation(description: "Observer metrics") + let forwardedMetrics = expectation(description: "Session delegate metrics") + let observer = RecordingNetworkObserver(completed: completed, metrics: metrics) + let delegate = MetricsSessionDelegate(received: forwardedMetrics) + let session = makeSession(delegate: delegate) + defer { session.invalidateAndCancel() } + NetworkSession.configure(requestTimeout: 30, resourceTimeout: 60, observer: observer) + _ = try await NetworkSession.data(for: URLRequest(url: URL(string: "https://observer.test/ok")!), in: session) + await fulfillment(of: [completed, metrics, forwardedMetrics], timeout: 3) + XCTAssertEqual(observer.metricsIDs, observer.starts.map(\.id)) + } + + func testDownloadFailuresAreObservedWithoutAResponseBody() async { + let completed = expectation(description: "Download failed") + let observer = RecordingNetworkObserver(completed: completed) + NetworkSession.configure(requestTimeout: 30, resourceTimeout: 60, observer: observer) + let session = makeSession() + defer { session.invalidateAndCancel() } + let callerCompleted = expectation(description: "Download caller") + NetworkSession.downloadTask(with: URL(string: "https://observer.test/offline")!, in: session) { _, _, error in + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + callerCompleted.fulfill() + }.resume() + await fulfillment(of: [completed, callerCompleted], timeout: 3) + XCTAssertEqual(observer.starts.map(\.id), observer.finishes.map(\.id)) + XCTAssertNil(observer.finishes.first?.responseBody) + XCTAssertEqual((observer.finishes.first?.error as? URLError)?.code, .notConnectedToInternet) + } + + func testReentrantObserverDoesNotBlockOrDeadlockRequests() async throws { + let completed = expectation(description: "Reentrant observer") + let observer = ReentrantNetworkObserver(completed: completed) + NetworkSession.configure(requestTimeout: 30, resourceTimeout: 60, observer: observer) + let session = makeSession() + defer { session.invalidateAndCancel() } + _ = try await NetworkSession.data(for: URLRequest(url: URL(string: "https://observer.test/ok")!), in: session) + await fulfillment(of: [completed], timeout: 3) + } + + private func makeSession(delegate: URLSessionDelegate? = nil) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ObserverURLProtocol.self] + return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + } +} + +private final class RecordingNetworkObserver: CrmSdkNetworkObserver, @unchecked Sendable { + let completed: XCTestExpectation + private let metrics: XCTestExpectation? + var collectsMetrics: Bool { metrics != nil } + private let lock = NSLock() + private var recordedStarts: [CrmSdkNetworkRequest] = [] + private var recordedFinishes: [CrmSdkNetworkResponse] = [] + private var recordedMetricsIDs: [UUID] = [] + private var order: [String] = [] + var starts: [CrmSdkNetworkRequest] { lock.withLock { recordedStarts } } + var finishes: [CrmSdkNetworkResponse] { lock.withLock { recordedFinishes } } + var metricsIDs: [UUID] { lock.withLock { recordedMetricsIDs } } + var eventOrder: [String] { lock.withLock { order } } + + init(completed: XCTestExpectation, metrics: XCTestExpectation? = nil) { + self.completed = completed + self.metrics = metrics + } + func requestDidStart(_ event: CrmSdkNetworkRequest) { + lock.withLock { recordedStarts.append(event); order.append("start") } + } + func requestDidComplete(_ event: CrmSdkNetworkResponse) { + lock.withLock { recordedFinishes.append(event); order.append("complete") } + completed.fulfill() + } + func requestDidCollectMetrics(_ metrics: URLSessionTaskMetrics, requestID: UUID) { + lock.withLock { recordedMetricsIDs.append(requestID) } + self.metrics?.fulfill() + } +} + +private final class MetricsSessionDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + let received: XCTestExpectation + init(received: XCTestExpectation) { self.received = received } + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + received.fulfill() + } +} + +private final class ReentrantNetworkObserver: CrmSdkNetworkObserver, @unchecked Sendable { + let completed: XCTestExpectation + init(completed: XCTestExpectation) { self.completed = completed } + func requestDidStart(_ event: CrmSdkNetworkRequest) { + NetworkSession.configure(requestTimeout: 30, resourceTimeout: 60) + } + func requestDidComplete(_ event: CrmSdkNetworkResponse) { completed.fulfill() } +} + +private final class ObserverURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { request.url?.host == "observer.test" } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + if request.url?.path.hasPrefix("/slow") == true { return } + if request.url?.path == "/offline" { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + return + } + let response = HTTPURLResponse(url: request.url!, statusCode: request.url?.path == "/bad" ? 400 : 200, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data("response-body".utf8)) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/Tests/ConsultantSdkTests/NetworkSessionTests.swift b/Tests/ConsultantSdkTests/NetworkSessionTests.swift new file mode 100644 index 0000000..aa55369 --- /dev/null +++ b/Tests/ConsultantSdkTests/NetworkSessionTests.swift @@ -0,0 +1,110 @@ +import Foundation +import XCTest +@testable import Core + +final class NetworkSessionTests: XCTestCase { + func testCoreHTTPErrorMessages() { + XCTAssertEqual( + CoreError.errorMessages(from: Data(#"{"errors":["first","second"]}"#.utf8)), + ["first", "second"] + ) + XCTAssertEqual( + CoreError.errorMessages(from: Data(#"{"error":"failed"}"#.utf8)), + ["failed"] + ) + XCTAssertEqual( + CoreError.errorMessages(from: Data(#"{"errors":[],"message":"retry"}"#.utf8)), + ["retry"] + ) + XCTAssertEqual(CoreError.errorMessages(from: Data(#"{"errors":[]}"#.utf8)), []) + XCTAssertEqual(CoreError.errorMessages(from: Data(" {invalid ".utf8)), ["{invalid"]) + XCTAssertEqual(CoreError.errorMessages(from: Data()), []) + XCTAssertTrue(CoreError.httpStatus(500, ["failed"]).localizedDescription.contains("failed")) + } + + override func tearDown() { + NetworkSession.configure(requestTimeout: 30, resourceTimeout: 60) + super.tearDown() + } + + func testHTTPResponsesAndTransportErrors() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + for (path, status) in [("success", 200), ("bad", 400), ("status", 503)] { + let (data, response) = try await NetworkSession.data( + for: URLRequest(url: URL(string: "https://network.test/\(path)")!), in: session + ) + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, status) + XCTAssertEqual(String(decoding: data, as: UTF8.self), #"{"result":"ok"}"#) + } + do { + _ = try await NetworkSession.data( + for: URLRequest(url: URL(string: "https://network.test/offline")!), in: session + ) + XCTFail("Expected connection failure") + } catch { + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + } + + func testTimeoutAndCancellation() async throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + NetworkSession.configure(requestTimeout: 0.05, resourceTimeout: 0.05) + do { + _ = try await NetworkSession.data( + for: URLRequest(url: URL(string: "https://network.test/slow-timeout")!), in: session + ) + XCTFail("Expected timeout") + } catch { + XCTAssertEqual((error as? URLError)?.code, .timedOut) + } + NetworkSession.configure(requestTimeout: 30, resourceTimeout: 60) + let task = Task { + try await NetworkSession.data( + for: URLRequest(url: URL(string: "https://network.test/slow-cancel")!), in: session + ) + } + try await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch { + XCTAssertEqual((error as? URLError)?.code, .cancelled) + } + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [NetworkSessionURLProtocol.self] + return URLSession(configuration: configuration) + } +} + +private final class NetworkSessionURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + request.url?.host == "network.test" + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + if request.url?.path.hasPrefix("/slow") == true { return } + if request.url?.path == "/offline" { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + return + } + let status = request.url?.path == "/status" ? 503 + : (request.url?.path == "/bad" ? 400 : 200) + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: nil, + headerFields: ["Content-Type": "application/json", "X-Request-Id": "server-request-1"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(#"{"result":"ok"}"#.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/Tests/OnlineConsultantTests/ConsultantApplicationLifecycleTests.swift b/Tests/OnlineConsultantTests/ConsultantApplicationLifecycleTests.swift new file mode 100644 index 0000000..3ba6770 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantApplicationLifecycleTests.swift @@ -0,0 +1,63 @@ +import UIKit +import XCTest +@testable import OnlineConsultant + +@MainActor +final class ConsultantApplicationLifecycleTests: XCTestCase { + func testKeepsConnectionAvailableUntilLastForegroundSceneLeaves() { + let center = NotificationCenter() + let lifecycle = ConsultantApplicationLifecycle( + notificationCenter: center, initiallyInForeground: true, + initialForegroundSceneIDs: ["first", "second"], + sceneIdentifier: { $0 as? String } + ) + var changes: [Bool] = [] + lifecycle.onForegroundChange = { changes.append($0) } + + center.post(name: UIApplication.willResignActiveNotification, object: nil) + center.post(name: UIScene.willDeactivateNotification, object: "first") + center.post(name: UIScene.didEnterBackgroundNotification, object: "first") + XCTAssertTrue(lifecycle.isInForeground) + XCTAssertTrue(changes.isEmpty) + + center.post(name: UIScene.didDisconnectNotification, object: "second") + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + XCTAssertEqual(changes, [false]) + + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + center.post(name: UIScene.willEnterForegroundNotification, object: "first") + center.post(name: UIScene.willEnterForegroundNotification, object: "first") + XCTAssertTrue(lifecycle.isInForeground) + XCTAssertEqual(changes, [false, true]) + } + + func testSupportsApplicationLifecycleWithoutScenes() { + let center = NotificationCenter() + let lifecycle = ConsultantApplicationLifecycle( + notificationCenter: center, initiallyInForeground: false, initialForegroundSceneIDs: [] + ) + var changes: [Bool] = [] + lifecycle.onForegroundChange = { changes.append($0) } + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + XCTAssertFalse(lifecycle.isInForeground) + XCTAssertEqual(changes, [true, false]) + } + + func testActivationRecoversWhenCreatedDuringForegroundTransition() { + let center = NotificationCenter() + let lifecycle = ConsultantApplicationLifecycle( + notificationCenter: center, initiallyInForeground: false, + initialForegroundSceneIDs: [], sceneIdentifier: { $0 as? String } + ) + var changes: [Bool] = [] + lifecycle.onForegroundChange = { changes.append($0) } + center.post(name: UIScene.didActivateNotification, object: "first") + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertTrue(lifecycle.isInForeground) + XCTAssertEqual(changes, [true]) + center.post(name: UIScene.didEnterBackgroundNotification, object: "first") + XCTAssertEqual(changes, [true, false]) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantConnectTests.swift b/Tests/OnlineConsultantTests/ConsultantConnectTests.swift new file mode 100644 index 0000000..6c0a605 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantConnectTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import OnlineConsultant + +final class ConsultantConnectTests: XCTestCase { + + func testDecodesConnectResponseWithMessages() throws { + let data = Data( + """ + { + "user": { "token": "user-token" }, + "session": { "id": 123 }, + "messages": [ + { "id": 7, "content": "Hello", "type": "text", "status": "sent", "from_me": false } + ] + } + """.utf8 + ) + + let response = try JSONDecoder().decode(ConsultantConnectResponse.self, from: data) + + XCTAssertEqual(response.user.token, "user-token") + XCTAssertEqual(response.session.id?.value, "123") + XCTAssertEqual(response.messages.map { $0.id?.value }, ["7"]) + XCTAssertEqual(response.messages.first?.content, "Hello") + XCTAssertTrue(response.actions.isEmpty) + } + + func testMissingOptionalConnectFieldsUseEmptyCollections() throws { + let data = Data( + """ + { "user": {}, "session": {} } + """.utf8 + ) + + let response = try JSONDecoder().decode(ConsultantConnectResponse.self, from: data) + + XCTAssertNil(response.config) + XCTAssertNil(response.behaviour) + XCTAssertTrue(response.messages.isEmpty) + XCTAssertTrue(response.actions.isEmpty) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantFileCacheTests.swift b/Tests/OnlineConsultantTests/ConsultantFileCacheTests.swift new file mode 100644 index 0000000..265ac5e --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantFileCacheTests.swift @@ -0,0 +1,310 @@ +import Foundation +import SQLiteStorage +import XCTest +@testable import Core +@testable import OnlineConsultant + +final class ConsultantFileCacheTests: XCTestCase { + + private var cacheRoot: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("ConsultantFileCacheTests-\(UUID().uuidString)") + } + + override func tearDownWithError() throws { + if let cacheRoot, FileManager.default.fileExists(atPath: cacheRoot.path) { + try FileManager.default.removeItem(at: cacheRoot) + } + + cacheRoot = nil + try super.tearDownWithError() + } + + func testStorePersistsFileAndRestoresAfterNewInstance() throws { + let remoteURL = URL(string: "https://example.com/files/file-1")! + let data = Data("image-data".utf8) + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + + let fileURL = try cache.store( + data: data, + fileID: "file-1", + contentType: "image/png", + remoteURL: remoteURL + ) + + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + XCTAssertEqual(try Data(contentsOf: fileURL), data) + + let restoredCache = ConsultantFileCache(rootDirectory: cacheRoot) + let restoredURL = try XCTUnwrap(restoredCache.cachedFileURL(fileID: "file-1")) + let restoredEntry = try XCTUnwrap(restoredCache.entry(fileID: "file-1")) + + XCTAssertEqual(try Data(contentsOf: restoredURL), data) + XCTAssertEqual(restoredEntry.fileID, "file-1") + XCTAssertEqual(restoredEntry.remoteURL, remoteURL) + XCTAssertEqual(restoredEntry.contentType, "image/png") + XCTAssertEqual(restoredEntry.size, Int64(data.count)) + } + + func testUpdateRemoteURLPersistsInDatabase() throws { + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + let initialURL = URL(string: "https://example.com/files/old")! + let updatedURL = URL(string: "https://example.com/files/new")! + + try cache.store(data: Data("file".utf8), fileID: "file-1", remoteURL: initialURL) + try cache.updateRemoteURL(fileID: "file-1", remoteURL: updatedURL) + + let restoredCache = ConsultantFileCache(rootDirectory: cacheRoot) + XCTAssertEqual(restoredCache.entry(fileID: "file-1")?.remoteURL, updatedURL) + } + + func testStoreRejectsEmptyFileID() { + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + + XCTAssertThrowsError(try cache.store(data: Data(), fileID: " ")) { error in + XCTAssertEqual(error as? ConsultantFileCacheError, .emptyFileID) + } + } + + func testUpdateRemoteURLRejectsMissingEntry() { + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + let remoteURL = URL(string: "https://example.com/files/file-1")! + + XCTAssertThrowsError(try cache.updateRemoteURL(fileID: "file-1", remoteURL: remoteURL)) { error in + XCTAssertEqual( + error as? ConsultantFileCacheError, + .missingEntry(fileID: "file-1") + ) + } + } + + func testRemoveAllClearsFilesAndMetadata() throws { + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + let fileURL = try cache.store(data: Data("file".utf8), fileID: "file-1") + + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + + try cache.removeAll() + + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + XCTAssertNil(cache.cachedFileURL(fileID: "file-1")) + XCTAssertEqual(cache.statistics(), ConsultantCacheStatistics(fileCount: 0, totalDiskSizeBytes: 0)) + } + + func testTrimRemovesFilesOlderThanPolicyAge() throws { + var currentDate = Date(timeIntervalSince1970: 1_000) + let cache = ConsultantFileCache( + rootDirectory: cacheRoot, + now: { currentDate } + ) + + try cache.store(data: Data("old".utf8), fileID: "old-file") + currentDate = Date(timeIntervalSince1970: 1_100) + try cache.store(data: Data("new".utf8), fileID: "new-file") + currentDate = Date(timeIntervalSince1970: 1_200) + + let statistics = try cache.trim(policy: OnlineConsultantCachePolicy(maxFileAge: 150)) + + XCTAssertNil(cache.entry(fileID: "old-file")) + XCTAssertNotNil(cache.entry(fileID: "new-file")) + XCTAssertEqual(statistics.fileCount, 1) + XCTAssertEqual(statistics.totalDiskSizeBytes, 3) + } + + func testTrimRemovesLeastRecentlyAccessedFilesUntilSizeFits() throws { + var currentDate = Date(timeIntervalSince1970: 1_000) + let cache = ConsultantFileCache( + rootDirectory: cacheRoot, + now: { currentDate } + ) + + try cache.store(data: Data("111".utf8), fileID: "file-1") + currentDate = Date(timeIntervalSince1970: 1_100) + try cache.store(data: Data("2222".utf8), fileID: "file-2") + currentDate = Date(timeIntervalSince1970: 1_200) + try cache.store(data: Data("33333".utf8), fileID: "file-3") + + let statistics = try cache.trim(policy: OnlineConsultantCachePolicy(maxDiskSizeBytes: 9)) + + XCTAssertNil(cache.entry(fileID: "file-1")) + XCTAssertNotNil(cache.entry(fileID: "file-2")) + XCTAssertNotNil(cache.entry(fileID: "file-3")) + XCTAssertEqual(statistics.fileCount, 2) + XCTAssertEqual(statistics.totalDiskSizeBytes, 9) + } + + func testReplacingFilePreservesCreationDateAndRemovesPreviousVersion() throws { + var date = Date(timeIntervalSince1970: 1_000.125) + let cache = ConsultantFileCache(rootDirectory: cacheRoot, now: { date }) + let originalURL = try cache.store(data: Data("old".utf8), fileID: "file") + date = Date(timeIntervalSince1970: 2_000.5) + let replacementURL = try cache.store(data: Data("replacement".utf8), fileID: "file", contentType: "text/plain") + XCTAssertNotEqual(originalURL, replacementURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: originalURL.path)) + let reopened = ConsultantFileCache(rootDirectory: cacheRoot) + let entry = try XCTUnwrap(reopened.entry(fileID: "file")) + XCTAssertEqual(entry.createdAt, Date(timeIntervalSince1970: 1_000.125)) + XCTAssertEqual(entry.lastAccessedAt, date) + XCTAssertEqual(entry.size, 11) + XCTAssertEqual(entry.contentType, "text/plain") + XCTAssertEqual(try Data(contentsOf: replacementURL), Data("replacement".utf8)) + } + + func testCachedFileAccessPersistsDateAndChangesEvictionOrder() throws { + var date = Date(timeIntervalSince1970: 1_000) + let cache = ConsultantFileCache(rootDirectory: cacheRoot, now: { date }) + try cache.store(data: Data("one".utf8), fileID: "first") + date = Date(timeIntervalSince1970: 1_100) + try cache.store(data: Data("two".utf8), fileID: "second") + date = Date(timeIntervalSince1970: 1_200) + XCTAssertNotNil(cache.cachedFileURL(fileID: "first")) + let reopened = ConsultantFileCache(rootDirectory: cacheRoot) + XCTAssertEqual(reopened.entry(fileID: "first")?.lastAccessedAt, date) + try reopened.trim(policy: .init(maxDiskSizeBytes: 3)) + XCTAssertNotNil(reopened.entry(fileID: "first")) + XCTAssertNil(reopened.entry(fileID: "second")) + } + + func testClearCanBeFollowedByStoreAndIsVisibleToAnotherInstance() throws { + let first = ConsultantFileCache(rootDirectory: cacheRoot) + try first.store(data: Data("old".utf8), fileID: "old") + let second = ConsultantFileCache(rootDirectory: cacheRoot) + try first.removeAll() + XCTAssertNil(second.entry(fileID: "old")) + let url = try second.store(data: Data("new".utf8), fileID: "new") + XCTAssertEqual(first.cachedFileURL(fileID: "new"), url) + XCTAssertEqual(first.statistics(), .init(fileCount: 1, totalDiskSizeBytes: 3)) + try second.removeAll() + XCTAssertEqual(first.statistics(), .init(fileCount: 0, totalDiskSizeBytes: 0)) + } + + func testOpeningReconcilesMissingFilesAndUnreferencedFiles() throws { + var missingURL: URL! + do { + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + missingURL = try cache.store(data: Data("missing".utf8), fileID: "missing") + try cache.store(data: Data("keep".utf8), fileID: "keep") + } + try FileManager.default.removeItem(at: missingURL) + let orphanURL = cacheRoot.appendingPathComponent("files/interrupted-write.data") + try Data("orphan".utf8).write(to: orphanURL) + let reopened = ConsultantFileCache(rootDirectory: cacheRoot) + XCTAssertNil(reopened.entry(fileID: "missing")) + XCTAssertNil(reopened.cachedFileURL(fileID: "missing")) + XCTAssertNotNil(reopened.entry(fileID: "keep")) + XCTAssertFalse(FileManager.default.fileExists(atPath: orphanURL.path)) + XCTAssertEqual(reopened.statistics(), .init(fileCount: 1, totalDiskSizeBytes: 4)) + } + + func testFailedMetadataReplacementKeepsOriginalFileAndDoesNotLeakNewFile() throws { + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + let originalURL = try cache.store(data: Data("original".utf8), fileID: "file") + let originalEntry = try XCTUnwrap(cache.entry(fileID: "file")) + let database = try SQLiteDatabase(url: databaseURL) + try database.withConnection { try $0.execute(""" + CREATE TRIGGER reject_replacement BEFORE UPDATE ON cache_files + BEGIN SELECT RAISE(FAIL, 'test metadata failure'); END; + """) } + XCTAssertThrowsError(try cache.store(data: Data("new".utf8), fileID: "file")) + XCTAssertEqual(cache.entry(fileID: "file"), originalEntry) + XCTAssertEqual(try Data(contentsOf: originalURL), Data("original".utf8)) + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: cacheRoot.appendingPathComponent("files").path).count, 1) + } + + func testFailedMetadataDeletionLeavesFileUntouched() throws { + let cache = ConsultantFileCache(rootDirectory: cacheRoot) + let url = try cache.store(data: Data("keep".utf8), fileID: "file") + let database = try SQLiteDatabase(url: databaseURL) + try database.withConnection { try $0.execute(""" + CREATE TRIGGER reject_deletion BEFORE DELETE ON cache_files + BEGIN SELECT RAISE(FAIL, 'test deletion failure'); END; + """) } + XCTAssertThrowsError(try cache.trim(policy: .init(maxDiskSizeBytes: 0))) + XCTAssertThrowsError(try cache.removeAll()) + XCTAssertEqual(try Data(contentsOf: url), Data("keep".utf8)) + XCTAssertNotNil(cache.entry(fileID: "file")) + } + + func testPartialTrimFailureRetainsRemainingFileAndRepairsAlreadyRemovedEntries() throws { + let manager = FailingCacheFileManager() + var date = Date(timeIntervalSince1970: 1_000) + let cache = ConsultantFileCache(rootDirectory: cacheRoot, fileManager: manager, now: { date }) + let firstURL = try cache.store(data: Data("one".utf8), fileID: "first") + date = Date(timeIntervalSince1970: 1_100) + let secondURL = try cache.store(data: Data("second".utf8), fileID: "second") + manager.blockedURL = secondURL + XCTAssertThrowsError(try cache.trim(policy: .init(maxDiskSizeBytes: 0))) + XCTAssertFalse(FileManager.default.fileExists(atPath: firstURL.path)) + XCTAssertNil(cache.entry(fileID: "first")) + XCTAssertNotNil(cache.entry(fileID: "second")) + XCTAssertEqual(try Data(contentsOf: secondURL), Data("second".utf8)) + XCTAssertEqual(cache.statistics(), .init(fileCount: 1, totalDiskSizeBytes: 6)) + manager.blockedURL = nil + XCTAssertEqual(try cache.trim(policy: .init(maxDiskSizeBytes: 0)), .init(fileCount: 0, totalDiskSizeBytes: 0)) + } + + func testFailedClearRetainsMetadataAndCanBeRetried() throws { + let manager = FailingCacheFileManager() + let cache = ConsultantFileCache(rootDirectory: cacheRoot, fileManager: manager) + let url = try cache.store(data: Data("keep".utf8), fileID: "file") + manager.blockedURL = cacheRoot.appendingPathComponent("files", isDirectory: true) + XCTAssertThrowsError(try cache.removeAll()) + XCTAssertEqual(cache.cachedFileURL(fileID: "file"), url) + let reopened = ConsultantFileCache(rootDirectory: cacheRoot) + XCTAssertNotNil(reopened.entry(fileID: "file")) + manager.blockedURL = nil + try cache.removeAll() + XCTAssertNil(reopened.entry(fileID: "file")) + } + + func testOpeningRemovesSupersededFileLeftByFailedFilesystemCleanup() throws { + let manager = FailingCacheFileManager() + let cache = ConsultantFileCache(rootDirectory: cacheRoot, fileManager: manager) + let oldURL = try cache.store(data: Data("old".utf8), fileID: "file") + manager.blockedURL = oldURL + let newURL = try cache.store(data: Data("new".utf8), fileID: "file") + XCTAssertTrue(FileManager.default.fileExists(atPath: oldURL.path)) + let reopened = ConsultantFileCache(rootDirectory: cacheRoot) + XCTAssertFalse(FileManager.default.fileExists(atPath: oldURL.path)) + XCTAssertEqual(reopened.cachedFileURL(fileID: "file"), newURL) + XCTAssertEqual(reopened.statistics(), .init(fileCount: 1, totalDiskSizeBytes: 3)) + } + + func testConcurrentInstancesDoNotDeleteFilesBeingPublished() throws { + let root = try XCTUnwrap(cacheRoot) + let first = ConsultantFileCache(rootDirectory: root) + let second = ConsultantFileCache(rootDirectory: root) + DispatchQueue.concurrentPerform(iterations: 12) { index in + let cache = index.isMultiple(of: 2) ? first : second + do { + try cache.store(data: Data("file-\(index)".utf8), fileID: "id-\(index)") + _ = ConsultantFileCache(rootDirectory: root).statistics() + } catch { XCTFail(error.localizedDescription) } + } + let reopened = ConsultantFileCache(rootDirectory: root) + for index in 0..<12 { + let url = try XCTUnwrap(reopened.cachedFileURL(fileID: "id-\(index)")) + XCTAssertEqual(try Data(contentsOf: url), Data("file-\(index)".utf8)) + } + XCTAssertEqual(reopened.statistics().fileCount, 12) + } + + private var databaseURL: URL { + cacheRoot.appendingPathComponent(ConsultantFileCacheStorage.databaseFileName) + } + +} + +private final class FailingCacheFileManager: FileManager, @unchecked Sendable { + var blockedURL: URL? + + override func removeItem(at URL: URL) throws { + if URL.standardizedFileURL == blockedURL?.standardizedFileURL { + throw CocoaError(.fileWriteNoPermission) + } + try super.removeItem(at: URL) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantImageLoaderTests.swift b/Tests/OnlineConsultantTests/ConsultantImageLoaderTests.swift new file mode 100644 index 0000000..381e246 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantImageLoaderTests.swift @@ -0,0 +1,109 @@ +import UIKit +import XCTest +@testable import OnlineConsultant + +final class ConsultantImageLoaderTests: XCTestCase { + + override func tearDown() { + ImageLoaderURLProtocol.reset() + super.tearDown() + } + + func testLoadImageUsesMemoryCacheForRepeatedURL() async throws { + let url = try XCTUnwrap(URL(string: "https://example.test/image.png")) + let sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.protocolClasses = [ImageLoaderURLProtocol.self] + let loader = ImageLoader( + session: URLSession(configuration: sessionConfiguration) + ) + ImageLoaderURLProtocol.responseData = try XCTUnwrap(Self.makeImageData()) + + let firstImage = await loadImage(from: url, using: loader) + let secondImage = await loadImage(from: url, using: loader) + + XCTAssertNotNil(firstImage) + XCTAssertNotNil(secondImage) + XCTAssertEqual(ImageLoaderURLProtocol.requestCount, 1) + } + + func testLoadImageReadsLocalSourcesAndCachesResult() async throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("ImageLoaderTests-\(UUID().uuidString).png") + try XCTUnwrap(Self.makeImageData()).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + let dataURL = try XCTUnwrap(URL(string: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR42mP8z8BQDwAFgwJ/l8dKWAAAAABJRU5ErkJggg==")) + for url in [fileURL, dataURL] { + let loader = ImageLoader() + let first = await loadImage(from: url, using: loader) + let second = await loadImage(from: url, using: loader) + XCTAssertNotNil(first, url.scheme ?? "") + XCTAssertNotNil(second, url.scheme ?? "") + XCTAssertNotNil(loader.cachedImage(for: url), url.scheme ?? "") + } + } + + private func loadImage( + from url: URL, + using loader: ImageLoader + ) async -> UIImage? { + await withCheckedContinuation { continuation in + loader.loadImage(from: url) { image in + continuation.resume(returning: image) + } + } + } + + private static func makeImageData() -> Data? { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) + return renderer.image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + }.pngData() + } +} + +private final class ImageLoaderURLProtocol: URLProtocol { + + private static let lock = NSLock() + private static var currentRequestCount = 0 + + static var responseData: Data? + + static var requestCount: Int { + lock.withLock { currentRequestCount } + } + + static func reset() { + lock.withLock { + currentRequestCount = 0 + responseData = nil + } + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.withLock { + Self.currentRequestCount += 1 + } + + let data = Self.responseData ?? Data() + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/png"] + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/Tests/OnlineConsultantTests/ConsultantLayoutTests.swift b/Tests/OnlineConsultantTests/ConsultantLayoutTests.swift new file mode 100644 index 0000000..0aa2fcd --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantLayoutTests.swift @@ -0,0 +1,113 @@ +import UIKit +import XCTest +@testable import OnlineConsultant + +@MainActor +final class ConsultantLayoutTests: XCTestCase { + + func testShortMessageFitsConfiguredManagerNameAfterLayoutAndReuse() throws { + let config = try JSONDecoder().decode(ConsultantConfig.self, from: Data(""" + {"manager_display_type":"custom","manager_name":"Александра Поддержка"} + """.utf8)) + let identity = ManagerIdentity(config: config) + let message = ConsultantMessage( + content: "Да", user: MessageUser(firstName: "Ян"), fromMe: false, messageType: .text + ) + + for width: CGFloat in [320, 390, 768] { + let height = MessageCollectionViewCell.preferredHeight(for: message, collectionWidth: width) + let cell = MessageCollectionViewCell(frame: CGRect(x: 0, y: 0, width: width, height: height)) + for manager in [identity, nil, identity] { + cell.prepareForReuse() + cell.configure(message: message, managerIdentity: manager) + cell.setNeedsLayout() + cell.layoutIfNeeded() + let author = try XCTUnwrap(descendantLabels(in: cell).first { + $0.accessibilityIdentifier == "messageAuthorLabel" + }) + XCTAssertEqual(author.text, manager?.displayName ?? "Ян") + XCTAssertGreaterThanOrEqual(author.bounds.width + 0.5, author.intrinsicContentSize.width) + } + } + } + + func testManagerNameDoesNotWidenHiddenAuthorOrOutgoingMessages() { + for outgoing in [false, true] { + let message = ConsultantMessage(content: "Да", fromMe: outgoing, messageType: .text) + for showsAuthor in [false, true] where outgoing || !showsAuthor { + let originalWidth = MessageCollectionViewCell.preferredBubbleWidth( + for: message, collectionWidth: 390, showsAuthor: showsAuthor + ) + let configuredWidth = MessageCollectionViewCell.preferredBubbleWidth( + for: message, collectionWidth: 390, showsAuthor: showsAuthor, + authorNameOverride: "Александра Поддержка" + ) + XCTAssertEqual(originalWidth, configuredWidth) + } + } + } + + private func descendantLabels(in view: UIView) -> [UILabel] { + view.subviews.flatMap { child in + (child as? UILabel).map { [$0] } ?? descendantLabels(in: child) + } + } + + func testReusedCellLaysOutOnlyCurrentMessageAtItsMeasuredHeight() throws { + let messages = [ + ConsultantMessage(content: "Hello", fromMe: false, messageType: .text), + ConsultantMessage(product: MessageProduct(name: "Product"), fromMe: false, messageType: .product), + ConsultantMessage(content: "Reply", quoteContent: "Quoted text", fromMe: false, messageType: .text), + ConsultantMessage(messageType: .contactForm, contactForm: ContactForm( + text: "Contact us", fields: [.name: ContactFormField(required: true)] + )), + ConsultantMessage(content: "OK", fromMe: true, messageType: .text) + ] + let bubbleTypes: [UIView.Type] = [ + TextMessageBubbleView.self, ProductMessageBubbleView.self, QuoteMessageBubbleView.self, + ContactFormMessageBubbleView.self, TextMessageBubbleView.self + ] + + for width: CGFloat in [320, 390, 768] { + let cell = MessageCollectionViewCell(frame: CGRect(x: 0, y: 0, width: width, height: 100)) + for (index, message) in messages.enumerated() { + cell.prepareForReuse() + let height = MessageCollectionViewCell.preferredHeight(for: message, collectionWidth: width) + cell.frame.size = CGSize(width: width, height: height) + cell.configure(message: message) + cell.layoutIfNeeded() + let container = try XCTUnwrap(cell.contentView.subviews.first { + $0.accessibilityIdentifier == "messageBubbleContainerView" + }) + XCTAssertEqual(container.subviews.count, 1) + let bubble = try XCTUnwrap(container.subviews.first) + XCTAssertTrue(type(of: bubble) == bubbleTypes[index]) + XCTAssertEqual(bubble.frame.width, container.bounds.width, accuracy: 0.5) + XCTAssertEqual(bubble.frame.height, height - 8, accuracy: 0.5) + XCTAssertFalse(bubble.isHidden) + } + } + } + + func testChatConfiguredBeforePresentationLaysOutAtContainerSize() throws { + let view = ConsultantView() + view.setCanLoadOlderMessages(false) + view.setMessages([ConsultantMessage(content: "Hello", fromMe: false, messageType: .text)]) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + defer { window.isHidden = true } + let controller = UIViewController() + controller.view = view + window.rootViewController = controller + window.makeKeyAndVisible() + view.layoutIfNeeded() + + let collection = try XCTUnwrap(view.subviews.flatMap(\.subviews).compactMap { $0 as? UICollectionView }.first) + collection.layoutIfNeeded() + XCTAssertEqual(collection.bounds.width, 390, accuracy: 0.5) + XCTAssertEqual(collection.numberOfItems(inSection: 0), 1) + XCTAssertGreaterThan(collection.bounds.height, 0) + let input = try XCTUnwrap(view.subviews.compactMap { $0 as? InputBar }.first) + XCTAssertGreaterThan(input.bounds.height, 0) + XCTAssertEqual(input.frame.maxY, view.safeAreaLayoutGuide.layoutFrame.maxY, accuracy: 0.5) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantMessageDateSeparatorTests.swift b/Tests/OnlineConsultantTests/ConsultantMessageDateSeparatorTests.swift new file mode 100644 index 0000000..3fb716b --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantMessageDateSeparatorTests.swift @@ -0,0 +1,98 @@ +import UIKit +import XCTest +@testable import OnlineConsultant + +final class ConsultantMessageDateSeparatorTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 7 * 3600)! + return calendar + } + + func testCalendarDayBoundariesUseLocalTime() throws { + let messages = [ + message("2026-09-01T16:59:59Z"), + message("2026-09-01T17:00:00Z"), + message("2026-09-02T10:00:00.123456Z") + ] + XCTAssertNotNil(MessageDateSeparator.date(messages: messages, index: 0, calendar: calendar)) + XCTAssertNotNil(MessageDateSeparator.date(messages: messages, index: 1, calendar: calendar)) + XCTAssertNil(MessageDateSeparator.date(messages: messages, index: 2, calendar: calendar)) + } + + func testMissingAndInvalidDatesDoNotProduceLabels() { + let messages = [message(nil), message("invalid"), message("2026-09-02T10:00:00Z")] + XCTAssertNil(MessageDateSeparator.date(messages: messages, index: 0)) + XCTAssertNil(MessageDateSeparator.date(messages: messages, index: 1)) + XCTAssertNotNil(MessageDateSeparator.date(messages: messages, index: 2)) + XCTAssertNil(MessageDateSeparator.date(messages: messages, index: 3)) + } + + func testRelativeDatesTakePrecedenceAcrossYearBoundary() throws { + let now = try XCTUnwrap(DateParser.date(from: "2026-01-01T01:00:00+07:00")) + let yesterday = try XCTUnwrap(DateParser.date(from: "2025-12-31T10:00:00+07:00")) + XCTAssertEqual(MessageDateSeparator.text(for: now, now: now, calendar: calendar), + ConsultantLocalization.messageDateToday.localized) + XCTAssertEqual(MessageDateSeparator.text(for: yesterday, now: now, calendar: calendar), + ConsultantLocalization.messageDateYesterday.localized) + } + + func testOlderDatesIncludeYearOnlyOutsideCurrentYear() throws { + let now = try XCTUnwrap(DateParser.date(from: "2026-10-28T10:00:00Z")) + for (timestamp, format) in [ + ("2026-09-02T10:00:00Z", ConsultantLocalization.messageDateFormat), + ("2025-09-02T10:00:00Z", ConsultantLocalization.messageDateYearFormat) + ] { + let date = try XCTUnwrap(DateParser.date(from: timestamp)) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ru") + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = format.localized + XCTAssertEqual(MessageDateSeparator.text(for: date, now: now, calendar: calendar, + locale: Locale(identifier: "ru")), + formatter.string(from: date)) + } + } + + func testSenderGroupBreaksAtDateBoundary() { + let messages = [message("2026-09-01T10:00:00Z"), message("2026-09-03T10:00:00Z")] + XCTAssertTrue(MessageGrouping.showsAuthor(messages: messages, index: 1)) + XCTAssertTrue(MessageGrouping.showsAvatar(messages: messages, index: 0)) + XCTAssertFalse(MessageGrouping.roundsAllCorners(messages: messages, index: 0)) + } + + @MainActor + func testDateLabelLayoutAndReuse() throws { + let message = message("2026-09-02T10:00:00Z") + let height = MessageCollectionViewCell.preferredHeight(for: message, collectionWidth: 390) + let cell = MessageCollectionViewCell(frame: CGRect(x: 0, y: 0, width: 390, + height: height + MessageCollectionViewCell.dateSeparatorHeight)) + cell.configure(message: message, dateSeparatorText: "2 сентября") + cell.layoutIfNeeded() + let label = try XCTUnwrap(cell.contentView.subviews.first { + $0.accessibilityIdentifier == "messageDateSeparatorLabel" + } as? UILabel) + let bubble = try XCTUnwrap(cell.contentView.subviews.first { + $0.accessibilityIdentifier == "messageBubbleContainerView" + }) + XCTAssertEqual(label.text, "2 сентября") + XCTAssertFalse(label.isHidden) + XCTAssertEqual(label.center.x, 195, accuracy: 0.5) + XCTAssertLessThan(label.frame.maxY, bubble.frame.minY) + XCTAssertFalse(cell.contentView.hasAmbiguousLayout) + + cell.prepareForReuse() + cell.frame.size.height = height + cell.configure(message: message) + cell.layoutIfNeeded() + XCTAssertTrue(label.isHidden) + XCTAssertNil(label.text) + XCTAssertEqual(bubble.frame.minY, 4, accuracy: 0.5) + } + + private func message(_ time: String?) -> ConsultantMessage { + ConsultantMessage(content: "Сообщение", time: time, user: MessageUser(firstName: "Анна"), + fromMe: false, messageType: .text, status: .sent) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantMessageGroupingTests.swift b/Tests/OnlineConsultantTests/ConsultantMessageGroupingTests.swift new file mode 100644 index 0000000..0b1b5e8 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantMessageGroupingTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import OnlineConsultant + +final class ConsultantMessageGroupingTests: XCTestCase { + + func testIncomingSenderGroup() { + for count in [2, 3] { + let messages = (0.. ConsultantMessage { + ConsultantMessage( + content: content, + user: MessageUser(firstName: firstName, lastName: lastName), + fromMe: false, + messageType: "text", + status: "sent" + ) + } + + private func outgoingMessage(content: String) -> ConsultantMessage { + ConsultantMessage( + content: content, + fromMe: true, + messageType: "text", + status: "sent" + ) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantMessageReadCoordinatorTests.swift b/Tests/OnlineConsultantTests/ConsultantMessageReadCoordinatorTests.swift new file mode 100644 index 0000000..53ba181 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantMessageReadCoordinatorTests.swift @@ -0,0 +1,138 @@ +import XCTest +@testable import Core +@testable import OnlineConsultant + +@MainActor +final class ConsultantMessageReadCoordinatorTests: XCTestCase { + + func testScheduleCoalescesPendingMessageIDsAndSendsOnlyGreatestID() async throws { + let networkEngine = MessageReadNetworkEngine() + let coordinator = makeCoordinator(networkEngine: networkEngine) + var errors: [Error] = [] + + coordinator.schedule( + messageID: "10", + session: "session-id", + userToken: "user-token", + handleError: { errors.append($0) } + ) + coordinator.schedule( + messageID: "12", + session: "session-id", + userToken: "user-token", + handleError: { errors.append($0) } + ) + coordinator.schedule( + messageID: "11", + session: "session-id", + userToken: "user-token", + handleError: { errors.append($0) } + ) + + try await waitUntil { networkEngine.postRequests.count == 1 } + + let request = try XCTUnwrap(networkEngine.postRequests.first) + XCTAssertEqual( + request.url.absoluteString, + "https://c.retailcrm.tech/api/client/v1/messages/12/read" + ) + XCTAssertEqual(request.headers["X-Site-Token"], "site-token") + XCTAssertEqual(request.headers["X-User-Token"], "user-token") + XCTAssertEqual(request.headers["X-Session-Id"], "session-id") + XCTAssertTrue(errors.isEmpty) + } + + func testScheduleIgnoresNonNumericAndAlreadySubmittedMessageIDs() async throws { + let networkEngine = MessageReadNetworkEngine() + let coordinator = makeCoordinator(networkEngine: networkEngine) + + coordinator.schedule( + messageID: "5", + session: "session-id", + userToken: "user-token", + handleError: { _ in } + ) + + try await waitUntil { networkEngine.postRequests.count == 1 } + + coordinator.schedule( + messageID: "4", + session: "session-id", + userToken: "user-token", + handleError: { _ in } + ) + coordinator.schedule( + messageID: "_local_1", + session: "session-id", + userToken: "user-token", + handleError: { _ in } + ) + try await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertEqual(networkEngine.postRequests.count, 1) + } + + func testResetCancelsPendingReadRequest() async throws { + let networkEngine = MessageReadNetworkEngine() + let coordinator = makeCoordinator(networkEngine: networkEngine) + + coordinator.schedule( + messageID: "7", + session: "session-id", + userToken: "user-token", + handleError: { _ in } + ) + coordinator.reset() + try await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertTrue(networkEngine.postRequests.isEmpty) + } +} + +private extension ConsultantMessageReadCoordinatorTests { + + func makeCoordinator( + networkEngine: MessageReadNetworkEngine + ) -> MessageReadCoordinator { + MessageReadCoordinator( + client: OnlineConsultantClient( + config: OnlineConsultantConfig(), + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "site-token")), + networkEngine: networkEngine + ), + debounceNanoseconds: 1_000_000 + ) + } + + func waitUntil( + _ condition: @escaping () -> Bool + ) async throws { + for _ in 0..<100 { + if condition() { + return + } + + try await Task.sleep(nanoseconds: 10_000_000) + } + + XCTFail("Timed out waiting for condition") + } +} + +private final class MessageReadNetworkEngine: ConsultantNetworkEngine { + + private(set) var postRequests: [ConsultantHTTPRequest] = [] + + func getJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + ConsultantHTTPResponse(statusCode: 200, body: Data()) + } + + func postJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + postRequests.append(request) + return ConsultantHTTPResponse(statusCode: 200, body: Data(#"{}"#.utf8)) + } + + func postRaw(_ request: ConsultantRawHTTPRequest) async throws -> ConsultantHTTPResponse { + ConsultantHTTPResponse(statusCode: 200, body: Data()) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantMessageRetryTests.swift b/Tests/OnlineConsultantTests/ConsultantMessageRetryTests.swift new file mode 100644 index 0000000..2bc5079 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantMessageRetryTests.swift @@ -0,0 +1,236 @@ +import UIKit +import XCTest +@testable import Core +@testable import OnlineConsultant + +@MainActor +final class ConsultantMessageRetryTests: XCTestCase { + func testTextRetryKeepsOneMessageAndCanBeRetriedAfterAnotherFailure() async throws { + let fixture = try await makeFixture() + defer { fixture.session.invalidateAndCancel() } + await expectFailure { try await fixture.controller.sendMessage("Hello") } + let local = try XCTUnwrap(fixture.controller.messages.first) + let id = try XCTUnwrap(local.id?.value) + XCTAssertTrue(local.canRetrySending) + + await expectFailure { try await fixture.controller.retryMessage(id: id) } + XCTAssertEqual(fixture.controller.messages.count, 1) + XCTAssertEqual(fixture.controller.messages.first?.id?.value, id) + XCTAssertEqual(fixture.controller.messages.first?.status, .failed) + + fixture.engine.configure(status: 200) + try await fixture.controller.retryMessage(id: id) + XCTAssertEqual(fixture.controller.messages.count, 1) + XCTAssertEqual(fixture.controller.messages.first?.id?.value, "server-3") + XCTAssertEqual(fixture.controller.messages.first?.content, "Hello") + XCTAssertEqual(fixture.controller.messages.first?.status, .sent) + XCTAssertEqual(fixture.engine.requests.count, 3) + XCTAssertEqual(Set(try fixture.engine.requests.map(canonicalBody)).count, 1) + try await fixture.controller.retryMessage(id: "server-3") + XCTAssertEqual(fixture.engine.requests.count, 3) + } + + func testAttachmentRetryReusesUploadedItemsAndDoesNotResendOtherMessages() async throws { + let fixture = try await makeFixture() + defer { fixture.session.invalidateAndCancel() } + let preview = Data([1, 2, 3]) + let attachments = [ + InputAttachment(fileName: "photo.png", contentType: "image/png", previewData: preview, + messageType: "image", status: .uploaded(fileID: FlexibleID("image-id"))), + InputAttachment(fileName: "document.pdf", status: .uploaded(fileID: FlexibleID("file-id"))) + ] + await expectFailure { + try await fixture.controller.sendMessage("Caption", attachments: attachments) + } + XCTAssertEqual(fixture.controller.messages.count, 3) + let failed = fixture.controller.messages + fixture.engine.configure(status: 200) + for message in failed { + let id = try XCTUnwrap(message.id?.value) + try await fixture.controller.retryMessage(id: id) + XCTAssertEqual(fixture.controller.messages.count, 3) + } + let requests = fixture.engine.requests + XCTAssertEqual(requests.count, 6) + for index in 0..<3 { + XCTAssertEqual(try canonicalBody(requests[index]), try canonicalBody(requests[index + 3])) + } + XCTAssertTrue(fixture.controller.messages.allSatisfy { $0.status == .sent }) + let image = try XCTUnwrap(fixture.controller.messages.first { $0.messageType == .image }) + XCTAssertEqual(image.items?.first?.id, "image-id") + XCTAssertEqual(image.items?.first?.url, "data:image/png;base64,\(preview.base64EncodedString())") + } + + func testRepeatedRetryWhileSendingDoesNotSendTwice() async throws { + let fixture = try await makeFixture() + defer { fixture.session.invalidateAndCancel() } + await expectFailure { try await fixture.controller.sendMessage("Hello") } + let id = try XCTUnwrap(fixture.controller.messages.first?.id?.value) + fixture.engine.configure(status: 200, hold: true) + let sending = Task { try await fixture.controller.retryMessage(id: id) } + await waitUntil { fixture.engine.hasPendingRequest } + XCTAssertEqual(fixture.controller.messages.first?.status, .sending) + try await fixture.controller.retryMessage(id: id) + XCTAssertEqual(fixture.engine.requests.count, 2) + fixture.engine.completePendingRequest() + try await sending.value + XCTAssertEqual(fixture.controller.messages.first?.status, .sent) + } + + func testFailedAttachmentHasContextMenuWithoutCopyableText() throws { + let view = ConsultantView() + let message = ConsultantMessage( + id: FlexibleID("_local_test"), items: [MessageItem(id: "file-id")], + fromMe: true, messageType: .file, status: .failed + ) + XCTAssertNil(message.clipboardText) + view.setMessages([message]) + let collection = try XCTUnwrap( + view.subviews.flatMap(\.subviews).compactMap { $0 as? UICollectionView }.first + ) + XCTAssertNotNil(view.collectionView( + collection, contextMenuConfigurationForItemAt: IndexPath(item: 0, section: 0), point: .zero + )) + for status in [MessageStatus.sending, .sent, .seen] { + view.setMessages([message.applyingStatus(status)]) + XCTAssertNil(view.collectionView( + collection, contextMenuConfigurationForItemAt: IndexPath(item: 0, section: 0), point: .zero + )) + } + XCTAssertFalse(ConsultantMessage( + id: FlexibleID("1"), content: "Incoming", fromMe: false, messageType: .text, status: .failed + ).canRetrySending) + } + + func testSendAndRetryFailuresDoNotPresentAlert() async throws { + let fixture = try await makeFixture() + defer { fixture.session.invalidateAndCancel() } + let screen = OnlineConsultantViewController(controller: fixture.controller) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = screen + window.makeKeyAndVisible() + defer { window.isHidden = true } + screen.loadViewIfNeeded() + screen.consultantView.onSendMessage?("Hello") + await waitUntil { fixture.controller.messages.first?.status == .failed } + let id = try XCTUnwrap(fixture.controller.messages.first?.id?.value) + XCTAssertNil(screen.presentedViewController) + screen.consultantView.onRetryMessage?(id) + await waitUntil { fixture.engine.requests.count == 2 && fixture.controller.messages.first?.status == .failed } + XCTAssertNil(screen.presentedViewController) + XCTAssertEqual(fixture.controller.messages.count, 1) + } + + private func makeFixture() async throws -> ( + controller: OnlineConsultantController, engine: RetryNetworkEngine, session: URLSession + ) { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [RetryConnectProtocol.self] + let session = URLSession(configuration: configuration) + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "site"), session: session, userTokenStorage: RetryTokenStorage() + ) + let engine = RetryNetworkEngine() + let controller = OnlineConsultantController( + client: OnlineConsultantClient( + config: OnlineConsultantConfig(initialMessagesLimit: 0), sessionRuntime: runtime, networkEngine: engine + ), + applicationLifecycle: ConsultantApplicationLifecycle( + notificationCenter: NotificationCenter(), initiallyInForeground: false, initialForegroundSceneIDs: [] + ) + ) + controller.start() + await waitUntil { controller.state == .ready(isAnonymous: true) } + return (controller, engine, session) + } + + private func expectFailure(_ operation: () async throws -> Void) async { + do { + try await operation() + XCTFail("Expected sending to fail") + } catch {} + } + + private func canonicalBody(_ request: ConsultantHTTPRequest) throws -> Data { + let body = try XCTUnwrap(request.body) + return try JSONSerialization.data( + withJSONObject: JSONSerialization.jsonObject(with: body), options: .sortedKeys + ) + } + + private func waitUntil(file: StaticString = #filePath, line: UInt = #line, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(2) + while !condition(), Date() < deadline { try? await Task.sleep(nanoseconds: 1_000_000) } + XCTAssertTrue(condition(), file: file, line: line) + } +} + +private final class RetryNetworkEngine: ConsultantNetworkEngine { + private let lock = NSLock() + private var storedRequests: [ConsultantHTTPRequest] = [] + private var status = 500 + private var hold = false + private var pending: CheckedContinuation? + var requests: [ConsultantHTTPRequest] { lock.withLock { storedRequests } } + var hasPendingRequest: Bool { lock.withLock { pending != nil } } + + func configure(status: Int, hold: Bool = false) { + lock.withLock { self.status = status; self.hold = hold } + } + + func getJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + ConsultantHTTPResponse(statusCode: 200, body: Data(#"{"url":"https://example.test/file"}"#.utf8)) + } + + func postJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + let (status, hold) = lock.withLock { + storedRequests.append(request) + return (self.status, self.hold) + } + if hold { + return await withCheckedContinuation { continuation in + lock.withLock { pending = continuation } + } + } + return response(status: status) + } + + func postRaw(_ request: ConsultantRawHTTPRequest) async throws -> ConsultantHTTPResponse { + XCTFail("Retry must not upload attachments again") + return response(status: 500) + } + + func completePendingRequest() { + let continuation = lock.withLock { () -> CheckedContinuation? in + defer { pending = nil } + return pending + } + continuation?.resume(returning: response(status: 200)) + } + + private func response(status: Int) -> ConsultantHTTPResponse { + ConsultantHTTPResponse( + statusCode: status, + body: Data("{\"message\":{\"id\":\"server-\(requests.count)\",\"time\":\"2026-10-28T10:00:00Z\",\"status\":\"sent\"}}".utf8) + ) + } +} + +private final class RetryConnectProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let data = Data(#"{"user":{"token":"user-token"},"session":{"id":1},"behaviour":{"widget":true}}"#.utf8) + client?.urlProtocol(self, didReceive: HTTPURLResponse( + url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil + )!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +private final class RetryTokenStorage: UserTokenStorage { + func getUserToken(siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws -> String? { nil } + func setUserToken(_ token: String?, siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws {} +} diff --git a/Tests/OnlineConsultantTests/ConsultantMessageStoreTests.swift b/Tests/OnlineConsultantTests/ConsultantMessageStoreTests.swift new file mode 100644 index 0000000..affa59e --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantMessageStoreTests.swift @@ -0,0 +1,111 @@ +import XCTest +@testable import OnlineConsultant + +final class ConsultantMessageStoreTests: XCTestCase { + + func testRealtimeInsertUpdateAndDelete() { + var store = MessageStore() + + store.applyRealtimeMessage(message(id: "1", content: "First", status: .sent)) + store.applyRealtimeMessage(message(id: "1", content: "Updated", status: .seen)) + + XCTAssertEqual(store.messageCount, 1) + XCTAssertEqual(store.message(id: "1")?.content, "Updated") + XCTAssertEqual(store.unreadIncomingMessagesCount, 0) + + store.applyRealtimeMessage(message(id: "1", status: .deleted)) + + XCTAssertTrue(store.isEmpty) + } + + func testMarkIncomingMessagesSeenUpToID() { + var store = MessageStore() + store.replaceAll([ + message(id: "1", status: .sent), + message(id: "2", status: .sent), + message(id: "3", status: .sent, fromMe: true) + ]) + + XCTAssertTrue(store.markIncomingMessagesSeen(upTo: "2")) + XCTAssertEqual(store.message(id: "1")?.status, .seen) + XCTAssertEqual(store.message(id: "2")?.status, .seen) + XCTAssertEqual(store.message(id: "3")?.status, .sent) + XCTAssertEqual(store.unreadIncomingMessagesCount, 0) + } + + func testLocalMessageIsReplacedByAcknowledgement() { + var store = MessageStore() + let localID = store.appendLocalOutgoingMessage("Hello") + + XCTAssertEqual(store.message(id: localID)?.status, .sending) + + XCTAssertTrue(store.replaceLocalMessage( + id: localID, + sentMessage: ConsultantSentMessage( + id: "42", + time: "2026-01-01T00:00:00Z", + updatedAt: nil, + status: .sent + ) + )) + XCTAssertNil(store.message(id: localID)) + XCTAssertEqual(store.message(id: "42")?.content, "Hello") + } + + func testUnreadCountTracksLoadedHistoryAndRealtimeChanges() { + var store = MessageStore() + XCTAssertEqual(store.unreadIncomingMessagesCount, 0) + store.replaceAll([message(id: "3", status: .sent)]) + XCTAssertEqual(store.unreadIncomingMessagesCount, 1) + + // Loading an older page counts new messages without double-counting overlapping ids. + store.merge([ + message(id: "1", status: .sent), + message(id: "2", status: .sent), + message(id: "3", status: .sent) + ]) + XCTAssertEqual(store.unreadIncomingMessagesCount, 3) + store.applyRealtimeMessage(message(id: "4", status: .sent)) + XCTAssertEqual(store.unreadIncomingMessagesCount, 4) + store.applyRealtimeMessage(message(id: "4", content: "Updated", status: .sent)) + XCTAssertEqual(store.unreadIncomingMessagesCount, 4) + store.applyRealtimeMessage(message(id: "4", status: .deleted)) + XCTAssertEqual(store.unreadIncomingMessagesCount, 3) + XCTAssertTrue(store.markIncomingMessagesSeen(upTo: "2")) + XCTAssertEqual(store.unreadIncomingMessagesCount, 1) + store.applyRealtimeMessage(message(id: "3", status: .seen)) + XCTAssertEqual(store.unreadIncomingMessagesCount, 0) + store.applyRealtimeMessage(message(id: "5", status: .sent)) + store.reset() + XCTAssertEqual(store.unreadIncomingMessagesCount, 0) + } + + func testUnreadCountExcludesReadDeletedOutgoingAndVirtualMessages() { + var store = MessageStore() + store.replaceAll([ + message(id: "1", status: .seen), + message(id: "2", status: .deleted), + message(id: "3", status: .sent, fromMe: true), + .introMessage(text: "Welcome"), + message(id: ConsultantMessage.offlineMessageID, status: .sent), + message(id: "4", status: .sent), + message(id: "5", status: .sent) + ]) + XCTAssertEqual(store.unreadIncomingMessagesCount, 2) + } + + private func message( + id: String, + content: String? = nil, + status: MessageStatus, + fromMe: Bool = false + ) -> ConsultantMessage { + ConsultantMessage( + id: FlexibleID(id), + content: content, + fromMe: fromMe, + messageType: .text, + status: status + ) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantMessageTypeTests.swift b/Tests/OnlineConsultantTests/ConsultantMessageTypeTests.swift new file mode 100644 index 0000000..b4d229c --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantMessageTypeTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import OnlineConsultant + +final class ConsultantMessageTypeTests: XCTestCase { + + func testMessageTypesMatchBackendJSON() throws { + let cases: [(String, MessageType)] = [ + ("text", .text), + ("system", .system), + ("command", .command), + ("order", .order), + ("product", .product), + ("file", .file), + ("image", .image), + ("auto_message", .unknown("auto_message")), + ("contact_form", .contactForm), + ("new_backend_type", .unknown("new_backend_type")) + ] + for (raw, expected) in cases { + XCTAssertEqual(MessageType(rawValue: raw), expected, raw) + let data = try JSONEncoder().encode(ConsultantMessage(id: FlexibleID("message-1"), messageType: expected)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["type"] as? String, raw, raw) + } + } + + func testMessageStatusesMatchBackendJSON() throws { + let cases: [(String, MessageStatus)] = [ + ("none", .none), + ("new", .new), + ("sent", .sent), + ("seen", .seen), + ("deleted", .deleted), + ("failed", .failed), + ("undefined", .undefined), + ("sending", .sending), + ("delivered", .delivered), + ("received", .unknown("received")) + ] + for (raw, expected) in cases { + XCTAssertEqual(MessageStatus(rawValue: raw), expected, raw) + let data = try JSONEncoder().encode(ConsultantMessage(id: FlexibleID("message-1"), status: expected)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(json["status"] as? String, raw, raw) + } + } + +} diff --git a/Tests/OnlineConsultantTests/ConsultantRealtimeEventTests.swift b/Tests/OnlineConsultantTests/ConsultantRealtimeEventTests.swift new file mode 100644 index 0000000..18ba318 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantRealtimeEventTests.swift @@ -0,0 +1,246 @@ +import XCTest +@testable import OnlineConsultant + +final class ConsultantRealtimeEventTests: XCTestCase { + + func testDecodesMessageEvent() throws { + let event = try ConsultantRealtimeEvent( + rawText: """ + { + "type": "message", + "data": { + "id": "24077908", + "content": "Здравствуйте", + "time": "2026-06-24T11:30:35.090792Z", + "updated_at": "2026-06-24T11:30:35.09123Z", + "from_me": false, + "type": "text", + "status": "sent", + "quote_content": "Цитата", + "user": { + "first_name": "Юрий", + "last_name": "Девятаев", + "avatar": "https://example.com/avatar.png" + } + } + } + """ + ) + + guard case .message(let message) = event else { + return XCTFail("Expected message event") + } + + XCTAssertEqual(message.id?.value, "24077908") + XCTAssertEqual(message.content, "Здравствуйте") + XCTAssertEqual(message.time, "2026-06-24T11:30:35.090792Z") + XCTAssertEqual(message.updatedAt, "2026-06-24T11:30:35.09123Z") + XCTAssertEqual(message.fromMe, false) + XCTAssertEqual(message.messageType, "text") + XCTAssertEqual(message.status, "sent") + XCTAssertEqual(message.quoteContent, "Цитата") + XCTAssertEqual(message.user?.firstName, "Юрий") + XCTAssertEqual(message.user?.lastName, "Девятаев") + XCTAssertEqual(message.user?.avatar, "https://example.com/avatar.png") + } + + func testDecodesFlexibleNumericMessageID() throws { + let event = try ConsultantRealtimeEvent( + rawText: """ + { + "type": "message", + "data": { + "id": 24077908, + "content": "Здравствуйте" + } + } + """ + ) + + guard case .message(let message) = event else { + return XCTFail("Expected message event") + } + + XCTAssertEqual(message.id?.value, "24077908") + } + + func testDecodesProductMessageEvent() throws { + let event = try ConsultantRealtimeEvent( + rawText: """ + { + "type": "message", + "data": { + "id": "103", + "type": "product", + "content": "", + "product": { + "name": "Chocolate candies", + "img": "https://example.test/products/77.png", + "unit": "pcs", + "cost": { + "value": 12.9, + "currency": "USD" + }, + "quantity": { + "value": 7, + "unit": "pcs" + } + } + } + } + """ + ) + + guard case .message(let message) = event else { + return XCTFail("Expected message event") + } + + XCTAssertEqual(message.id?.value, "103") + XCTAssertEqual(message.messageType, "product") + XCTAssertEqual(message.product?.name, "Chocolate candies") + XCTAssertEqual(message.product?.img, "https://example.test/products/77.png") + XCTAssertEqual(message.product?.unit, "pcs") + XCTAssertEqual(message.product?.cost?.value, 12.9) + XCTAssertEqual(message.product?.cost?.currency, "USD") + XCTAssertEqual(message.product?.quantity?.value, 7) + XCTAssertEqual(message.product?.quantity?.unit, "pcs") + } + + func testDecodesOrderMessageEvent() throws { + let event = try ConsultantRealtimeEvent( + rawText: """ + { + "type": "message", + "data": { + "id": "104", + "type": "order", + "content": "", + "order": { + "external_id": 501, + "number": "10042", + "url": "https://example.test/orders/10042", + "cost": { + "value": 3700, + "currency": "RUB" + }, + "items": [ + { + "external_id": 1001, + "name": "Увлажняющий крем", + "url": "https://example.test/products/cream", + "price": { + "value": 1200, + "currency": "RUB" + }, + "quantity": { + "value": 2, + "unit": "шт" + } + } + ], + "delivery": { + "name": "Курьер", + "address": "Москва, Тверская улица, 7", + "comment": "Позвонить за час", + "price": { + "value": 300, + "currency": "RUB" + } + }, + "discount": { + "value": 250, + "currency": "RUB" + }, + "payments": [ + { + "name": "Картой", + "amount": { + "value": 3450, + "currency": "RUB" + }, + "status": { + "name": "Оплачено", + "payed": true + } + } + ], + "status": { + "code": "new", + "name": "Новый" + }, + "date": "2026-06-30T09:40:00Z" + } + } + } + """ + ) + + guard case .message(let message) = event else { + return XCTFail("Expected message event") + } + + XCTAssertEqual(message.id?.value, "104") + XCTAssertEqual(message.messageType, "order") + XCTAssertEqual(message.order?.number?.value, "10042") + XCTAssertEqual(message.order?.items.first?.name, "Увлажняющий крем") + XCTAssertEqual(message.order?.items.first?.url, "https://example.test/products/cream") + XCTAssertEqual(message.order?.items.first?.quantity?.value, 2) + XCTAssertEqual(message.order?.payments.first?.amount?.value, 3450) + XCTAssertEqual(message.order?.payments.first?.amount?.currency, "RUB") + XCTAssertEqual(message.order?.payments.first?.name, "Картой") + XCTAssertEqual(message.order?.payments.first?.status?.name, "Оплачено") + XCTAssertEqual(message.order?.payments.first?.status?.payed, true) + XCTAssertEqual( + message.orderNumberLabel, + ConsultantLocalization.orderLabelNumber.format("10042") + ) + XCTAssertEqual(message.orderDisplayItems.first?.name, "Увлажняющий крем") + XCTAssertTrue(message.orderDisplayItems.first?.details.contains("2 шт") == true) + XCTAssertTrue(message.orderDisplayItems.first?.details.contains("1") == true) + XCTAssertEqual( + message.orderDisplayFields.map(\.title), + [ + ConsultantLocalization.orderFieldDelivery.localized, + ConsultantLocalization.orderFieldAddress.localized, + ConsultantLocalization.orderFieldComment.localized, + ConsultantLocalization.orderFieldDeliveryCost.localized, + ConsultantLocalization.orderFieldDiscount.localized, + ConsultantLocalization.orderFieldTotal.localized, + ConsultantLocalization.orderFieldPayment.localized + ] + ) + } + + func testDecodesOfflineStatusEvent() throws { + let event = try ConsultantRealtimeEvent( + rawText: """ + { + "type": "offline_status", + "data": { + "is_offline": true + } + } + """ + ) + + XCTAssertEqual(event, .offlineStatus(ConsultantOfflineStatus(isOffline: true))) + } + + func testDecodesUnknownEventWithoutFailure() throws { + let event = try ConsultantRealtimeEvent( + rawText: """ + { + "type": "custom_event", + "data": { + "enabled": true + } + } + """ + ) + + XCTAssertEqual( + event, + .unknown(type: "custom_event", data: .object(["enabled": .bool(true)])) + ) + } +} diff --git a/Tests/OnlineConsultantTests/ConsultantRealtimeLifecycleTests.swift b/Tests/OnlineConsultantTests/ConsultantRealtimeLifecycleTests.swift new file mode 100644 index 0000000..8d687c7 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantRealtimeLifecycleTests.swift @@ -0,0 +1,168 @@ +import UIKit +import XCTest +@testable import Core +@testable import OnlineConsultant + +@MainActor +final class ConsultantRealtimeLifecycleTests: XCTestCase { + func testForegroundRestoresSocketWithLatestCursorEvenWhenErrorRetriesAreDisabled() async throws { + let fixture = makeFixture(inForeground: true, autoReconnect: false) + defer { fixture.session.invalidateAndCancel() } + fixture.controller.start() + await waitUntil { fixture.socket.requests.count == 1 } + fixture.socket.emit(#"{"type":"message","data":{"id":"1","content":"Hello","updated_at":"2026-10-27T10:00:00Z"}}"#) + await waitUntil { fixture.controller.messages.count == 1 } + + fixture.center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + await waitUntil { fixture.socket.activeCount == 0 } + XCTAssertEqual(fixture.controller.state, .ready(isAnonymous: true)) + fixture.center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + fixture.center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + await waitUntil { fixture.socket.requests.count == 2 } + XCTAssertEqual(fixture.socket.maximumActiveCount, 1) + let request = try XCTUnwrap(fixture.socket.requests.last) + let query = URLComponents(url: request.url, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "updated_at" }?.value, "2026-10-27T10:00:00Z") + XCTAssertEqual(query?.first { $0.name == "token" }?.value, "user-token") + + fixture.controller.sessionDidDisconnect() + await waitUntil { fixture.controller.state == .idle && fixture.socket.activeCount == 0 } + fixture.center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + fixture.center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + await settle() + XCTAssertEqual(fixture.socket.requests.count, 2) + } + + func testConnectionCompletedInBackgroundWaitsForForeground() async { + let fixture = makeFixture(inForeground: false) + defer { fixture.session.invalidateAndCancel() } + fixture.controller.start() + await waitUntil { fixture.controller.state == .ready(isAnonymous: true) } + XCTAssertTrue(fixture.socket.requests.isEmpty) + fixture.center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + await waitUntil { fixture.socket.requests.count == 1 } + fixture.controller.sessionDidDisconnect() + await waitUntil { fixture.socket.activeCount == 0 } + } + + func testBackgroundCancelsPendingErrorRetry() async { + let fixture = makeFixture(inForeground: true, retryDelay: 0.2) + defer { fixture.session.invalidateAndCancel() } + fixture.controller.start() + await waitUntil { fixture.socket.requests.count == 1 } + fixture.socket.fail() + await settle() + fixture.center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try? await Task.sleep(nanoseconds: 300_000_000) + XCTAssertEqual(fixture.socket.requests.count, 1) + fixture.center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + await waitUntil { fixture.socket.requests.count == 2 } + fixture.controller.sessionDidDisconnect() + await waitUntil { fixture.socket.activeCount == 0 } + } + + func testErrorRetryRemainsEnabledInForeground() async { + let fixture = makeFixture(inForeground: true, retryDelay: 0.01) + defer { fixture.session.invalidateAndCancel() } + fixture.controller.start() + await waitUntil { fixture.socket.requests.count == 1 } + fixture.socket.fail() + await waitUntil { fixture.socket.requests.count == 2 } + XCTAssertEqual(fixture.socket.maximumActiveCount, 1) + fixture.controller.sessionDidDisconnect() + await waitUntil { fixture.socket.activeCount == 0 } + } + + func testIdleControllerDoesNotOpenSocketOnForeground() async { + let fixture = makeFixture(inForeground: false) + defer { fixture.session.invalidateAndCancel() } + fixture.center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + await settle() + XCTAssertTrue(fixture.socket.requests.isEmpty) + XCTAssertEqual(fixture.controller.state, .idle) + } + + private func makeFixture( + inForeground: Bool, autoReconnect: Bool = true, retryDelay: TimeInterval = 3 + ) -> (controller: OnlineConsultantController, socket: LifecycleWebSocketEngine, center: NotificationCenter, session: URLSession) { + let center = NotificationCenter() + let lifecycle = ConsultantApplicationLifecycle( + notificationCenter: center, initiallyInForeground: inForeground, initialForegroundSceneIDs: [] + ) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [LifecycleConnectProtocol.self] + let session = URLSession(configuration: config) + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "site"), session: session, userTokenStorage: LifecycleTokenStorage() + ) + let socket = LifecycleWebSocketEngine() + let client = OnlineConsultantClient( + config: OnlineConsultantConfig( + autoReconnectWebSocket: autoReconnect, webSocketReconnectDelay: retryDelay, initialMessagesLimit: 0 + ), sessionRuntime: runtime, webSocketEngine: socket + ) + return (OnlineConsultantController(client: client, applicationLifecycle: lifecycle), socket, center, session) + } + + private func waitUntil( + file: StaticString = #filePath, line: UInt = #line, _ condition: () -> Bool + ) async { + let deadline = Date().addingTimeInterval(2) + while !condition(), Date() < deadline { try? await Task.sleep(nanoseconds: 1_000_000) } + XCTAssertTrue(condition(), file: file, line: line) + } + + private func settle() async { + try? await Task.sleep(nanoseconds: 30_000_000) + } +} + +private final class LifecycleWebSocketEngine: ConsultantWebSocketEngine { + private let lock = NSLock() + private var storedRequests: [ConsultantWebSocketRequest] = [] + private var continuations: [UUID: AsyncThrowingStream.Continuation] = [:] + private var maximum = 0 + var requests: [ConsultantWebSocketRequest] { lock.withLock { storedRequests } } + var activeCount: Int { lock.withLock { continuations.count } } + var maximumActiveCount: Int { lock.withLock { maximum } } + + func open(_ request: ConsultantWebSocketRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let id = UUID() + lock.withLock { + storedRequests.append(request) + continuations[id] = continuation + maximum = max(maximum, continuations.count) + } + continuation.onTermination = { [weak self] _ in + guard let self else { return } + _ = self.lock.withLock { self.continuations.removeValue(forKey: id) } + } + } + } + + func emit(_ text: String) { + lock.withLock { Array(continuations.values) }.forEach { $0.yield(.event(rawText: text)) } + } + + func fail() { + lock.withLock { Array(continuations.values) }.forEach { $0.finish(throwing: URLError(.networkConnectionLost)) } + } +} + +private final class LifecycleConnectProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let data = Data(#"{"user":{"token":"user-token"},"session":{"id":1},"behaviour":{"widget":true}}"#.utf8) + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +private final class LifecycleTokenStorage: UserTokenStorage { + func getUserToken(siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws -> String? { nil } + func setUserToken(_ token: String?, siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws {} +} diff --git a/Tests/OnlineConsultantTests/ConsultantViewScrollingTests.swift b/Tests/OnlineConsultantTests/ConsultantViewScrollingTests.swift new file mode 100644 index 0000000..81330c9 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantViewScrollingTests.swift @@ -0,0 +1,223 @@ +import UIKit +import XCTest +@testable import OnlineConsultant + +@MainActor +final class ConsultantViewScrollingTests: XCTestCase { + + func testPendingMessageInsertionDoesNotOverrideManualScroll() async throws { + let (window, view, collectionView) = try makeChat() + defer { window.isHidden = true } + var messages = makeHistory() + view.setMessages(messages) + await finishAnimations() + + messages.append(makeOutgoingMessage(id: "_local_1", status: .sending)) + view.setMessages(messages) + let offset = scrollToHistory(view: view, collectionView: collectionView) + await finishAnimations() + + XCTAssertEqual(collectionView.contentOffset.y, offset, accuracy: 1) + + messages[messages.count - 1] = makeOutgoingMessage(id: "100", status: .delivered) + view.setMessages(messages) + await finishAnimations() + + XCTAssertEqual(collectionView.contentOffset.y, offset, accuracy: 1) + } + + func testDeliveryStatusChangesPreserveHistoryPosition() async throws { + let (window, view, collectionView) = try makeChat() + defer { window.isHidden = true } + var messages = makeHistory() + [makeOutgoingMessage(id: "_local_1", status: .sending)] + view.setMessages(messages) + await finishAnimations() + let offset = scrollToHistory(view: view, collectionView: collectionView) + + for (id, status) in [ + ("_local_1", MessageStatus.sent), + ("100", .delivered), + ("100", .seen) + ] { + messages[messages.count - 1] = makeOutgoingMessage(id: id, status: status) + view.setMessages(messages) + await finishAnimations() + + XCTAssertEqual(collectionView.contentOffset.y, offset, accuracy: 1) + } + } + + func testNewMessageAndDeliveryStatusKeepBottomWhenNotScrolling() async throws { + let (window, view, collectionView) = try makeChat() + defer { window.isHidden = true } + var messages = makeHistory() + view.setMessages(messages) + await finishAnimations() + + messages.append(makeOutgoingMessage(id: "_local_1", status: .sending)) + view.setMessages(messages) + await finishAnimations() + XCTAssertEqual(collectionView.contentOffset.y, bottomOffset(in: collectionView), accuracy: 1) + + messages[messages.count - 1] = makeOutgoingMessage(id: "100", status: .delivered) + view.setMessages(messages) + await finishAnimations() + XCTAssertEqual(collectionView.contentOffset.y, bottomOffset(in: collectionView), accuracy: 1) + } + + func testComposerAnimationDoesNotOverrideManualScroll() async throws { + let (window, view, collectionView) = try makeChat() + defer { window.isHidden = true } + view.setMessages(makeHistory()) + await finishAnimations() + + let inputBar = try XCTUnwrap(view.subviews.compactMap { $0 as? InputBar }.first) + let textView = try XCTUnwrap(inputBar.subviews.compactMap { $0 as? UITextView }.first) + textView.text = "First line\nSecond line\nThird line" + inputBar.textViewDidChange(textView) + await finishAnimations() + + view.clearMessageInput() + let offset = scrollToHistory(view: view, collectionView: collectionView) + await finishAnimations() + + XCTAssertEqual(collectionView.contentOffset.y, offset, accuracy: 1) + } + + func testImmediateDeliveryStatusUpdateKeepsBottomWhenNotScrolling() async throws { + let (window, view, collectionView) = try makeChat() + defer { window.isHidden = true } + var messages = makeHistory() + view.setMessages(messages) + await finishAnimations() + + let content = Array(repeating: "Outgoing message", count: 8).joined(separator: "\n") + messages.append(makeOutgoingMessage(id: "_local_1", status: .sending, content: content)) + view.setMessages(messages) + messages[messages.count - 1] = makeOutgoingMessage(id: "100", status: .delivered, content: content) + view.setMessages(messages) + await finishAnimations() + + XCTAssertEqual(collectionView.contentOffset.y, bottomOffset(in: collectionView), accuracy: 1) + } + + func testKeyboardAnimationDoesNotOverrideManualScroll() async throws { + let (window, view, collectionView) = try makeChat() + defer { window.isHidden = true } + view.setMessages(makeHistory()) + await finishAnimations() + + NotificationCenter.default.post( + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + userInfo: [ + UIResponder.keyboardFrameEndUserInfoKey: CGRect(x: 0, y: 544, width: 390, height: 300), + UIResponder.keyboardAnimationDurationUserInfoKey: 0.25 + ] + ) + let offset = scrollToHistory(view: view, collectionView: collectionView) + await finishAnimations() + + XCTAssertEqual(collectionView.contentOffset.y, offset, accuracy: 1) + } + + func testEmptyKeyboardFrameDoesNotLeaveComposerRaisedDuringTransform() async throws { + let (window, view, _) = try makeChat() + defer { window.isHidden = true } + let inputBar = try XCTUnwrap(view.subviews.compactMap { $0 as? InputBar }.first) + + NotificationCenter.default.post( + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + userInfo: [ + UIResponder.keyboardFrameEndUserInfoKey: CGRect(x: 0, y: 544, width: 390, height: 300), + UIResponder.keyboardAnimationDurationUserInfoKey: 0 + ] + ) + await finishAnimations() + XCTAssertLessThan(inputBar.frame.maxY, view.safeAreaLayoutGuide.layoutFrame.maxY - 100) + + view.transform = CGAffineTransform(rotationAngle: 0.01).scaledBy(x: 1.001, y: 1.001) + for frame in [ + CGRect(x: 0, y: 874, width: 402, height: 0), + CGRect(x: 0, y: 544, width: 402, height: 0) + ] { + NotificationCenter.default.post( + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + userInfo: [ + UIResponder.keyboardFrameEndUserInfoKey: frame, + UIResponder.keyboardAnimationDurationUserInfoKey: 0 + ] + ) + await finishAnimations() + XCTAssertEqual(inputBar.frame.maxY, view.safeAreaLayoutGuide.layoutFrame.maxY, accuracy: 0.5) + } + } + + private func makeChat() throws -> (UIWindow, ConsultantView, UICollectionView) { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let controller = UIViewController() + let view = ConsultantView(frame: window.bounds) + view.setCanLoadOlderMessages(false) + controller.view = view + window.rootViewController = controller + window.makeKeyAndVisible() + view.layoutIfNeeded() + let collectionView = try XCTUnwrap( + view.subviews.flatMap(\.subviews).compactMap { $0 as? UICollectionView }.first + ) + return (window, view, collectionView) + } + + private func makeHistory() -> [ConsultantMessage] { + (1...40).map { + ConsultantMessage( + id: FlexibleID(String($0)), + content: "History message \($0)", + time: "2026-10-20T10:00:00Z", + fromMe: false, + messageType: .text, + status: .seen + ) + } + } + + private func makeOutgoingMessage( + id: String, + status: MessageStatus, + content: String = "Outgoing message" + ) -> ConsultantMessage { + ConsultantMessage( + id: FlexibleID(id), + content: content, + time: "2026-10-20T10:01:00Z", + fromMe: true, + messageType: .text, + status: status + ) + } + + private func scrollToHistory(view: ConsultantView, collectionView: UICollectionView) -> CGFloat { + collectionView.delegate?.scrollViewWillBeginDragging?(collectionView) + let offset = bottomOffset(in: collectionView) - 300 + collectionView.setContentOffset(CGPoint(x: 0, y: offset), animated: false) + // UIKit sets isDragging during a real pan; supply it for the delegate callback. + view.scrollViewDidScroll(DraggingScrollView()) + collectionView.delegate?.scrollViewDidEndDragging?(collectionView, willDecelerate: false) + return offset + } + + private func bottomOffset(in collectionView: UICollectionView) -> CGFloat { + collectionView.contentSize.height - collectionView.bounds.height + + collectionView.adjustedContentInset.bottom + } + + private func finishAnimations() async { + try? await Task.sleep(nanoseconds: 500_000_000) + } +} + +private final class DraggingScrollView: UIScrollView { + override var isDragging: Bool { true } +} diff --git a/Tests/OnlineConsultantTests/ConsultantWebSocketTests.swift b/Tests/OnlineConsultantTests/ConsultantWebSocketTests.swift new file mode 100644 index 0000000..1819aa0 --- /dev/null +++ b/Tests/OnlineConsultantTests/ConsultantWebSocketTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import Core +@testable import OnlineConsultant + +final class ConsultantWebSocketTests: XCTestCase { + + func testCustomBackendIsUsedForHTTPAndWebSocket() async throws { + let network = FakeNetworkEngine(response: .init(statusCode: 200, body: Data("{}".utf8))) + let socket = FakeWebSocketEngine() + let client = OnlineConsultantClient( + config: OnlineConsultantConfig(), + sessionRuntime: SessionRuntime(config: SessionConfig( + siteToken: "site-token", baseURL: "http://localhost:8080/proxy/api/client/v1/" + )), + networkEngine: network, webSocketEngine: socket + ) + _ = try await client.loadMessages(session: "session", userToken: "user") + XCTAssertEqual(network.lastGetRequest?.url.absoluteString, + "http://localhost:8080/proxy/api/client/v1/messages?limit=50&Offset=0") + _ = client.subscribeRealtimeEvents(session: "session", userToken: "user") + XCTAssertEqual(socket.lastRequest?.url.absoluteString, + "ws://localhost:8080/proxy/api/client/v1/ws?token=user&session=session&updated_at=undefined") + } + + func testSubscribeRealtimeEventsBuildsURLAndYieldsDecodedEvents() async throws { + let webSocketEngine = FakeWebSocketEngine( + events: [ + .event(rawText: #"{"type":"message","data":{"id":"1","content":"Hello"}}"#) + ] + ) + let client = OnlineConsultantClient( + config: OnlineConsultantConfig(), + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "site-token")), + networkEngine: FakeNetworkEngine(), + webSocketEngine: webSocketEngine + ) + + var iterator = client.subscribeRealtimeEvents( + session: "session-id", + userToken: "user-token" + ).makeAsyncIterator() + let event = try await iterator.next() + + XCTAssertEqual( + webSocketEngine.lastRequest?.url.absoluteString, + "wss://c.retailcrm.tech/api/client/v1/ws?token=user-token&session=session-id&updated_at=undefined" + ) + + guard case .message(let message) = event else { + return XCTFail("Expected message event") + } + + XCTAssertEqual(message.id?.value, "1") + XCTAssertEqual(message.content, "Hello") + } + + func testSubscribeRealtimeEventsIncludesUpdatedAt() { + let webSocketEngine = FakeWebSocketEngine() + let client = OnlineConsultantClient( + config: OnlineConsultantConfig(), + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "site-token")), + networkEngine: FakeNetworkEngine(), + webSocketEngine: webSocketEngine + ) + + _ = client.subscribeRealtimeEvents( + session: "session-id", + userToken: "user-token", + updatedAt: "2026-06-24T10:20:30.123456Z" + ) + + XCTAssertEqual( + webSocketEngine.lastRequest?.url.absoluteString, + "wss://c.retailcrm.tech/api/client/v1/ws?token=user-token&session=session-id&updated_at=2026-06-24T10:20:30.123456Z" + ) + } + +} + +private final class FakeWebSocketEngine: ConsultantWebSocketEngine { + + private let events: [ConsultantWebSocketEvent] + private(set) var lastRequest: ConsultantWebSocketRequest? + + init(events: [ConsultantWebSocketEvent] = []) { + self.events = events + } + + func open(_ request: ConsultantWebSocketRequest) -> AsyncThrowingStream { + lastRequest = request + + return AsyncThrowingStream { continuation in + events.forEach { continuation.yield($0) } + continuation.finish() + } + } +} + +private final class FakeNetworkEngine: ConsultantNetworkEngine { + + private let response: ConsultantHTTPResponse + private(set) var lastGetRequest: ConsultantHTTPRequest? + + init(response: ConsultantHTTPResponse = ConsultantHTTPResponse(statusCode: 200, body: Data())) { + self.response = response + } + + func getJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + lastGetRequest = request + return response + } + + func postJSON(_ request: ConsultantHTTPRequest) async throws -> ConsultantHTTPResponse { + ConsultantHTTPResponse(statusCode: 200, body: Data()) + } + + func postRaw(_ request: ConsultantRawHTTPRequest) async throws -> ConsultantHTTPResponse { + ConsultantHTTPResponse(statusCode: 200, body: Data()) + } +} diff --git a/Tests/OnlineConsultantTests/OnlineConsultantConfigTests.swift b/Tests/OnlineConsultantTests/OnlineConsultantConfigTests.swift new file mode 100644 index 0000000..ae588f3 --- /dev/null +++ b/Tests/OnlineConsultantTests/OnlineConsultantConfigTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import Core +@testable import OnlineConsultant + +final class ConsultantConfigTests: XCTestCase { + + func testDefaultConfigValues() { + let config = CrmSdkConfig(siteToken: "site-token") + + XCTAssertEqual(config.consultantConfig.connectTimeout, 30) + XCTAssertEqual(config.requestTimeout, 30) + XCTAssertEqual(config.resourceTimeout, 60) + XCTAssertEqual(config.consultantConfig.initialMessagesLimit, 50) + XCTAssertEqual(config.consultantConfig.cachePolicy, OnlineConsultantCachePolicy()) + } + + func testLinkSettingsReflectDecodedConfiguration() throws { + let data = Data(""" + { + "links": [{"type": "telegram", "url": "retailcrm"}], + "links_mode": "widget", + "links_orientation": "horizontal", + "always_show_links_on_desktops": true + } + """.utf8) + let config = try JSONDecoder().decode(ConsultantConfig.self, from: data) + + XCTAssertEqual( + config.linkSettings, + LinkSettings( + links: [ConsultantLink(type: .telegram, url: "retailcrm")], + linksMode: .widget, + linksOrientation: .horizontal, + alwaysShowLinksOnDesktops: true + ) + ) + } +} diff --git a/Tests/OnlineConsultantTests/OnlineConsultantTests.swift b/Tests/OnlineConsultantTests/OnlineConsultantTests.swift new file mode 100644 index 0000000..f0f7840 --- /dev/null +++ b/Tests/OnlineConsultantTests/OnlineConsultantTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Core +@testable import OnlineConsultant + +@MainActor +final class OnlineConsultantTests: XCTestCase { + + func testControllerPublishesInitialState() { + let controller = OnlineConsultantController( + config: OnlineConsultantConfig(), + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "site-token")) + ) + var observedState: ControllerState? + var observedUnread: Int? + + controller.onStateChange = { observedState = $0 } + controller.onUnreadIncomingMessagesCountChange = { observedUnread = $0 } + + XCTAssertEqual(observedState, .idle) + XCTAssertEqual(observedUnread, 0) + XCTAssertEqual(controller.unreadIncomingMessagesCount, 0) + let observer = OnlineConsultantObserver(onlineConsultant: controller) + XCTAssertEqual(observer.unreadIncomingMessagesCount, 0) + var observerCount: Int? + observer.onUnreadIncomingMessagesCountChange = { observerCount = $0 } + XCTAssertEqual(observerCount, 0) + XCTAssertTrue(controller.messages.isEmpty) + XCTAssertEqual(controller.linkSettings, LinkSettings()) + } +} diff --git a/Tests/OnlineConsultantTests/OnlineConsultantThemeTests.swift b/Tests/OnlineConsultantTests/OnlineConsultantThemeTests.swift new file mode 100644 index 0000000..a287b44 --- /dev/null +++ b/Tests/OnlineConsultantTests/OnlineConsultantThemeTests.swift @@ -0,0 +1,261 @@ +import UIKit +import XCTest +@testable import OnlineConsultant + +final class ConsultantThemeTests: XCTestCase { + + func testThemeUsesDefaultLightPaletteWithoutConfig() { + let theme = ConsultantTheme(config: nil) + + assertColor(theme.brandColor, red: 0, green: 94, blue: 235) + assertColor(theme.brandAlphaColor, red: 0, green: 94, blue: 235, alpha: 0.12) + assertColor(theme.screenBackgroundColor, red: 255, green: 255, blue: 255) + assertColor(theme.consultantBubbleColor, red: 244, green: 246, blue: 248) + assertColor(theme.visitorBubbleColor, red: 0, green: 94, blue: 235) + assertColor(theme.mainTextColor, red: 255, green: 255, blue: 255) + assertColor(theme.primaryTextColor, red: 30, green: 34, blue: 72) + assertColor(theme.areaIconColor, red: 99, green: 111, blue: 127, alpha: 0.47) + XCTAssertEqual(theme.roundedElements, true) + } + + func testThemeBuildsColorsFromBackendVisualConfig() throws { + let config = try JSONDecoder().decode( + ConsultantConfig.self, + from: Data( + """ + { + "elements_color": "FF5500", + "bg_color": "dark", + "inverse": true, + "element_shape": "rectangular form" + } + """.utf8 + ) + ) + + let theme = ConsultantTheme(config: config) + + assertColor(theme.brandColor, red: 255, green: 85, blue: 0) + assertColor(theme.brandAlphaColor, red: 255, green: 85, blue: 0, alpha: 0.12) + assertColor(theme.screenBackgroundColor, red: 10, green: 10, blue: 10) + assertColor(theme.bottomBarColor, red: 10, green: 10, blue: 10) + assertColor(theme.consultantBubbleColor, red: 255, green: 255, blue: 255, alpha: 0.10) + assertColor(theme.visitorBubbleColor, red: 255, green: 85, blue: 0) + assertColor(theme.mainTextColor, red: 30, green: 34, blue: 72) + assertColor(theme.mainIconColor, red: 30, green: 34, blue: 72) + assertColor(theme.primaryTextColor, red: 255, green: 255, blue: 255) + assertColor(theme.messageDateColor, red: 175, green: 185, blue: 195) + assertColor(theme.dividerColor, red: 0, green: 0, blue: 0, alpha: 0) + assertColor(theme.areaBackgroundColor, red: 255, green: 255, blue: 255, alpha: 0.10) + assertColor(theme.areaTextColor, red: 255, green: 255, blue: 255) + assertColor(theme.areaBorderColor, red: 255, green: 255, blue: 255, alpha: 0.10) + XCTAssertEqual(theme.roundedElements, false) + } + + func testThemeFallsBackToDefaultBrandColorWhenConfigColorIsInvalid() throws { + let config = try JSONDecoder().decode( + ConsultantConfig.self, + from: Data(#"{ "elements_color": "not-a-color" }"#.utf8) + ) + + let theme = ConsultantTheme(config: config) + + assertColor(theme.brandColor, red: 0, green: 94, blue: 235) + assertColor(theme.visitorBubbleColor, red: 0, green: 94, blue: 235) + } + + func testMessageBubbleStyleUsesThemeColors() throws { + let theme = try makeTheme( + """ + { + "elements_color": "FF5500", + "bg_color": "dark", + "inverse": true + } + """ + ) + + assertColor( + MessageBubbleStyle.bubbleColor(isOutgoing: true, theme: theme), + red: 255, + green: 85, + blue: 0 + ) + assertColor( + MessageBubbleStyle.bubbleColor(isOutgoing: false, theme: theme), + red: 255, + green: 255, + blue: 255, + alpha: 0.10 + ) + assertColor( + MessageBubbleStyle.textColor(isOutgoing: true, theme: theme), + red: 30, + green: 34, + blue: 72 + ) + assertColor( + MessageBubbleStyle.textColor(isOutgoing: false, theme: theme), + red: 255, + green: 255, + blue: 255 + ) + } + + @MainActor + func testConsultantViewAppliesBackendThemeToRootAndInputBar() throws { + let config = try decodeConfig( + """ + { + "elements_color": "FF5500", + "bg_color": "dark", + "inverse": true, + "element_shape": "rectangular form" + } + """ + ) + let view = ConsultantView(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + + view.setConsultantConfig(config) + view.layoutIfNeeded() + + let textView = try XCTUnwrap( + view.recursiveSubviews() + .compactMap { $0 as? UITextView } + .first { $0.accessibilityIdentifier == "messageInputTextView" } + ) + let sendButton = try XCTUnwrap( + view.recursiveSubviews() + .compactMap { $0 as? UIButton } + .first { $0.accessibilityIdentifier == "sendMessageButton" } + ) + + assertColor(view.backgroundColor ?? .clear, red: 10, green: 10, blue: 10) + assertColor(textView.backgroundColor ?? .clear, red: 255, green: 255, blue: 255, alpha: 0.10) + assertColor(textView.textColor ?? .clear, red: 255, green: 255, blue: 255) + assertColor(sendButton.backgroundColor ?? .clear, red: 99, green: 111, blue: 127, alpha: 0.20) + assertColor(sendButton.tintColor, red: 30, green: 34, blue: 72) + XCTAssertFalse(sendButton.isEnabled) + XCTAssertFalse(sendButton.adjustsImageWhenDisabled) + XCTAssertEqual(textView.layer.cornerRadius, 4) + XCTAssertEqual(sendButton.layer.cornerRadius, 4) + } + + @MainActor + func testConsultantViewPassesBackendThemeAndManagerIdentityToMessageCells() throws { + let config = try decodeConfig( + """ + { + "elements_color": "FF5500", + "element_shape": "rectangular form", + "manager_display_type": "custom", + "manager_name": "Анна Поддержка" + } + """ + ) + let view = ConsultantView(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + let incomingMessage = ConsultantMessage( + id: FlexibleID("message-1"), + content: "Добрый день", + time: "2026-06-30T09:35:00Z", + user: MessageUser(firstName: "Евгений", lastName: "Петровский"), + fromMe: false, + messageType: "text", + status: "sent" + ) + let outgoingMessage = ConsultantMessage( + id: FlexibleID("message-2"), + content: "Здравствуйте", + time: "2026-06-30T09:36:00Z", + fromMe: true, + messageType: "text", + status: "sent" + ) + + view.setConsultantConfig(config) + view.setMessages([incomingMessage, outgoingMessage]) + view.layoutIfNeeded() + drainMainRunLoop() + + let collectionView = try XCTUnwrap(view.messagesCollectionViewForTests()) + let incomingCell = try XCTUnwrap( + collectionView.dataSource?.collectionView( + collectionView, + cellForItemAt: IndexPath(item: 0, section: 0) + ) as? MessageCollectionViewCell + ) + let outgoingCell = try XCTUnwrap( + collectionView.dataSource?.collectionView( + collectionView, + cellForItemAt: IndexPath(item: 1, section: 0) + ) as? MessageCollectionViewCell + ) + incomingCell.layoutIfNeeded() + outgoingCell.layoutIfNeeded() + + let authorLabel = incomingCell.contentView + .recursiveSubviews() + .compactMap { $0 as? UILabel } + .first { $0.accessibilityIdentifier == "messageAuthorLabel" } + let outgoingBubbleView = outgoingCell.contentView + .recursiveSubviews() + .compactMap { $0 as? MessageBubbleView } + .first + + XCTAssertEqual(authorLabel?.text, "Анна Поддержка") + assertColor(outgoingBubbleView?.backgroundColor ?? .clear, red: 255, green: 85, blue: 0) + XCTAssertEqual(outgoingBubbleView?.layer.cornerRadius, 4) + } +} + +private func makeTheme(_ json: String) throws -> ConsultantTheme { + let config = try decodeConfig(json) + return ConsultantTheme(config: config) +} + +private func decodeConfig(_ json: String) throws -> ConsultantConfig { + try JSONDecoder().decode( + ConsultantConfig.self, + from: Data(json.utf8) + ) +} + +private func assertColor( + _ color: UIColor, + red expectedRed: CGFloat, + green expectedGreen: CGFloat, + blue expectedBlue: CGFloat, + alpha expectedAlpha: CGFloat = 1, + accuracy: CGFloat = 0.002, + file: StaticString = #filePath, + line: UInt = #line +) { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + XCTAssertTrue(color.getRed(&red, green: &green, blue: &blue, alpha: &alpha), file: file, line: line) + XCTAssertEqual(red, expectedRed / 255, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(green, expectedGreen / 255, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(blue, expectedBlue / 255, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(alpha, expectedAlpha, accuracy: accuracy, file: file, line: line) +} + +private func drainMainRunLoop() { + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) +} + +private extension UIView { + + func recursiveSubviews() -> [UIView] { + subviews + subviews.flatMap { $0.recursiveSubviews() } + } +} + +private extension ConsultantView { + + func messagesCollectionViewForTests() -> UICollectionView? { + recursiveSubviews().compactMap { $0 as? UICollectionView }.first + } +} diff --git a/Tests/OnlineConsultantTests/ProductImagePreviewURLTests.swift b/Tests/OnlineConsultantTests/ProductImagePreviewURLTests.swift new file mode 100644 index 0000000..4d03b4a --- /dev/null +++ b/Tests/OnlineConsultantTests/ProductImagePreviewURLTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import OnlineConsultant + +final class ProductImagePreviewURLTests: XCTestCase { + func testDirectSignedURLPreservesExistingPercentEscapes() { + let source = "https://files.example.com/photo%20one.png?signature=a%2Fb%3D" + XCTAssertEqual(ProductImagePreviewURL.make(imageURLString: source, imageWorkers: [])?.absoluteString, source) + } + + func testWorkerURLPreservesSignedSourceWithoutDoubleEncoding() { + let source = "https://files.example.com/photo%20one.png?signature=a%2Fb%3D" + XCTAssertEqual( + ProductImagePreviewURL.make(imageURLString: source, imageWorkers: ["images.example.com"], resize: "304x-")?.absoluteString, + "https://images.example.com/r/304x-/" + source + ) + } + + func testUnescapedSpacesAreEncodedOnceAndProtocolRelativeURLsAreNormalized() { + XCTAssertEqual( + ProductImagePreviewURL.make(imageURLString: " //files.example.com/photo one.png ", imageWorkers: [])?.absoluteString, + "https://files.example.com/photo%20one.png" + ) + } +} diff --git a/Tests/OnlineConsultantTests/RemoteFileCacheLoaderTests.swift b/Tests/OnlineConsultantTests/RemoteFileCacheLoaderTests.swift new file mode 100644 index 0000000..aa1be27 --- /dev/null +++ b/Tests/OnlineConsultantTests/RemoteFileCacheLoaderTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import OnlineConsultant + +final class RemoteFileCacheLoaderTests: XCTestCase { + + func testCacheRemoteFileDownloadsAndStoresFile() async throws { + let cacheRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("RemoteFileCacheLoaderTests-\(UUID().uuidString)") + addTeardownBlock { try? FileManager.default.removeItem(at: cacheRoot) } + + let remoteURL = URL(string: "https://example.test/photo.png")! + let data = Data("image-data".utf8) + let dataLoader = RemoteFileDataLoader( + result: .success( + ( + data, + HTTPURLResponse( + url: remoteURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/png"] + )! + ) + ) + ) + let fileCache = ConsultantFileCache(rootDirectory: cacheRoot) + let loader = RemoteFileCacheLoader( + fileCache: fileCache, + dataLoader: dataLoader + ) + + let localURL = try await loader.cacheRemoteFile( + fileID: "file-1", + remoteURL: remoteURL + ) + let entry = try XCTUnwrap(fileCache.entry(fileID: "file-1")) + + XCTAssertEqual(dataLoader.requests.map(\.url), [remoteURL]) + XCTAssertEqual(try Data(contentsOf: localURL), data) + XCTAssertEqual(fileCache.cachedFileURL(fileID: "file-1"), localURL) + XCTAssertEqual(entry.remoteURL, remoteURL) + XCTAssertEqual(entry.contentType, "image/png") + XCTAssertEqual(entry.size, Int64(data.count)) + } + + func testHTTPFailuresPreserveStatusAndMessageWithoutCachingResponse() async throws { + for (status, body, message) in [(403, #"{"error":"Link expired"}"#, "Link expired"), (404, "not-found", "not-found")] { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let url = try XCTUnwrap(URL(string: "https://example.test/photo.png")) + let cache = ConsultantFileCache(rootDirectory: root) + let loader = RemoteFileCacheLoader(fileCache: cache, dataLoader: RemoteFileDataLoader(result: .success(( + Data(body.utf8), HTTPURLResponse(url: url, statusCode: status, httpVersion: nil, headerFields: nil)! + )))) + do { + _ = try await loader.cacheRemoteFile(fileID: "file", remoteURL: url) + XCTFail("Expected HTTP \(status)") + } catch { + XCTAssertEqual(error as? ConsultantClientError, .httpStatus(status, [message]), "HTTP \(status)") + XCTAssertEqual(ConsultantError.from(error), .client(.httpStatus(status, [message])), "HTTP \(status)") + } + XCTAssertNil(cache.cachedFileURL(fileID: "file"), "HTTP \(status)") + XCTAssertEqual(cache.statistics().fileCount, 0, "HTTP \(status)") + } + } + +} + +private final class RemoteFileDataLoader: ConsultantRemoteFileDataLoading { + + private let queue = DispatchQueue(label: "OnlineConsultantTests.RemoteFileDataLoader") + private let result: Result<(Data, URLResponse), Error> + private var recordedRequests: [URLRequest] = [] + + var requests: [URLRequest] { + queue.sync { + recordedRequests + } + } + + init(result: Result<(Data, URLResponse), Error>) { + self.result = result + } + + func loadData(for request: URLRequest) async throws -> (Data, URLResponse) { + queue.sync { + recordedRequests.append(request) + } + + return try result.get() + } +} diff --git a/Tests/PushNotificationExtensionTests/NetworkObserverTests.swift b/Tests/PushNotificationExtensionTests/NetworkObserverTests.swift new file mode 100644 index 0000000..b0d730c --- /dev/null +++ b/Tests/PushNotificationExtensionTests/NetworkObserverTests.swift @@ -0,0 +1,74 @@ +import Foundation +import UserNotifications +import XCTest +@testable import PushNotificationExtension + +final class ExtensionNetworkObserverTests: XCTestCase { + func testStatusAndImageRequestsAreObservedWithAnExtensionOnlyImport() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ExtensionObserverURLProtocol.self] + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + let databaseURL = FileManager.default.temporaryDirectory.appendingPathComponent("observer-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: databaseURL) } + let observed = expectation(description: "Status and image completion events") + observed.expectedFulfillmentCount = 2 + let observer = ExtensionNetworkObserver(completed: observed) + let handler = PushNotificationHandler( + siteToken: "test-site", session: session, + retryQueue: PushStatusRetryQueue(storage: .init(databaseURL: databaseURL), baseDelay: 3_600, monitorsNetwork: false), + networkObserver: observer + ) + let content = UNMutableNotificationContent() + content.userInfo = ["r_group_push": true, "push_id": "app_1", "image_url": "https://observer.test/image.png"] + let request = UNNotificationRequest(identifier: "observed", content: content, trigger: nil) + let enriched = expectation(description: "Notification handling completes") + XCTAssertTrue(handler.handle(request: request, content: content) { _ in enriched.fulfill() }) + await fulfillment(of: [observed, enriched], timeout: 3) + let starts = observer.starts + let finishes = observer.finishes + XCTAssertEqual(starts.count, 2) + XCTAssertEqual(Set(starts.map(\.id)), Set(finishes.map(\.id))) + let image = try XCTUnwrap(starts.first { $0.request.url?.path == "/image.png" }) + let imageResult = try XCTUnwrap(finishes.first { $0.id == image.id }) + XCTAssertNil(imageResult.responseBody) + XCTAssertEqual((imageResult.error as? URLError)?.code, .notConnectedToInternet) + let status = try XCTUnwrap(starts.first { $0.id != image.id }) + XCTAssertEqual(status.request.httpMethod, "POST") + XCTAssertEqual(status.request.value(forHTTPHeaderField: "X-Site-Token"), "test-site") + let statusResult = try XCTUnwrap(finishes.first { $0.id == status.id }) + XCTAssertEqual((statusResult.response as? HTTPURLResponse)?.statusCode, 204) + XCTAssertNil(statusResult.error) + } +} + +private final class ExtensionNetworkObserver: CrmSdkNetworkObserver, @unchecked Sendable { + private let completed: XCTestExpectation + private let lock = NSLock() + private var recordedStarts: [CrmSdkNetworkRequest] = [] + private var recordedFinishes: [CrmSdkNetworkResponse] = [] + var starts: [CrmSdkNetworkRequest] { lock.withLock { recordedStarts } } + var finishes: [CrmSdkNetworkResponse] { lock.withLock { recordedFinishes } } + init(completed: XCTestExpectation) { self.completed = completed } + func requestDidStart(_ event: CrmSdkNetworkRequest) { lock.withLock { recordedStarts.append(event) } } + func requestDidComplete(_ event: CrmSdkNetworkResponse) { + lock.withLock { recordedFinishes.append(event) } + completed.fulfill() + } +} + +private final class ExtensionObserverURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + if request.url?.path == "/image.png" { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } else { + let response = HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data()) + client?.urlProtocolDidFinishLoading(self) + } + } + override func stopLoading() {} +} diff --git a/Tests/PushNotificationExtensionTests/PushNotificationExtensionTests.swift b/Tests/PushNotificationExtensionTests/PushNotificationExtensionTests.swift new file mode 100644 index 0000000..b36e1f1 --- /dev/null +++ b/Tests/PushNotificationExtensionTests/PushNotificationExtensionTests.swift @@ -0,0 +1,194 @@ +import Foundation +import UserNotifications +import XCTest +@testable import PushNotificationExtension + +final class PushNotificationExtensionTests: XCTestCase { + private var session: URLSession! + private var handler: PushNotificationHandler! + private var retryQueue: PushStatusRetryQueue! + + override func setUp() { + super.setUp() + ExtensionURLProtocol.reset() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ExtensionURLProtocol.self] + session = URLSession(configuration: configuration) + let retryDatabaseURL = FileManager.default.temporaryDirectory.appendingPathComponent("PushNotificationExtensionTests-\(UUID().uuidString).sqlite") + addTeardownBlock { try? FileManager.default.removeItem(at: retryDatabaseURL) } + retryQueue = PushStatusRetryQueue(storage: .init(databaseURL: retryDatabaseURL), baseDelay: 3_600, monitorsNetwork: false) + handler = PushNotificationHandler( + siteToken: "test-site", session: session, + retryQueue: retryQueue + ) + } + + override func tearDown() { + session.invalidateAndCancel() + handler = nil + session = nil + retryQueue = nil + super.tearDown() + } + + func testUnrelatedNotificationsAreLeftToIntegrator() async { + let content = UNMutableNotificationContent() + content.title = "Own notification" + content.userInfo = ["provider": "other"] + let completion = expectation(description: "Unrelated notification must not be enriched") + completion.isInverted = true + XCTAssertFalse(handler.handle(request: request(content), content: content) { _ in completion.fulfill() }) + await fulfillment(of: [completion], timeout: 0.1) + XCTAssertEqual(content.title, "Own notification") + XCTAssertEqual(content.userInfo["provider"] as? String, "other") + XCTAssertEqual(ExtensionURLProtocol.requests.count, 0) + } + + func testDeliveryUsesCustomBackend() async throws { + handler = PushNotificationHandler( + siteToken: "test-site", baseURL: "http://localhost:8080/proxy/", session: session, retryQueue: retryQueue + ) + let content = crmContent() + let reported = expectation(description: "Custom backend received status") + XCTAssertTrue(handler.handle(request: request(content), content: content) { _ in reported.fulfill() }) + await fulfillment(of: [reported], timeout: 3) + XCTAssertEqual(ExtensionURLProtocol.requests.first?.url?.absoluteString, + "http://localhost:8080/proxy/api/client/v1/push-tokens/set-status") + } + + func testEnrichSendsStatusAndMarksContent() async throws { + let content = crmContent() + content.userInfo["r_group_category"] = "news" + let reported = expectation(description: "Delivery reported") + XCTAssertTrue(handler.handle(request: request(content), content: content) { result in + XCTAssertEqual(result.categoryIdentifier, "news") + XCTAssertEqual(result.userInfo["retailcrm_delivered_by_extension"] as? Bool, true) + XCTAssertEqual(result.userInfo["push_id"] as? String, "app_1") + reported.fulfill() + }) + await fulfillment(of: [reported], timeout: 3) + XCTAssertEqual(content.userInfo["retailcrm_delivered_by_extension"] as? Bool, true) + XCTAssertEqual(content.userInfo["push_id"] as? String, "app_1") + let captured = try XCTUnwrap(ExtensionURLProtocol.requests.first) + XCTAssertEqual(captured.httpMethod, "POST") + XCTAssertEqual(captured.url?.absoluteString, "https://c.retailcrm.tech/api/client/v1/push-tokens/set-status") + XCTAssertEqual(captured.value(forHTTPHeaderField: "X-Site-Token"), "test-site") + XCTAssertEqual(captured.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(captured.value(forHTTPHeaderField: "Content-Type"), "application/json") + let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData(captured)) as? [String: Any]) + XCTAssertEqual(body["push_id"] as? String, "app_1") + XCTAssertEqual(body["status"] as? String, "delivered") + XCTAssertGreaterThan(try XCTUnwrap((body["changed_at"] as? NSNumber)?.int64Value), 0) + XCTAssertEqual(ExtensionURLProtocol.requests.count, 1) + } + + func testDeliveryFailureDoesNotPreventEnrichment() async { + ExtensionURLProtocol.reset(error: URLError(.notConnectedToInternet)) + let content = crmContent() + content.userInfo["r_group_category"] = "news" + let request = request(content) + let enriched = expectation(description: "Content enriched despite delivery failure") + XCTAssertTrue(handler.handle(request: request, content: content) { result in + XCTAssertEqual(result.categoryIdentifier, "news") + enriched.fulfill() + }) + await fulfillment(of: [enriched], timeout: 3) + XCTAssertEqual(ExtensionURLProtocol.requests.count, 1) + } + + func testImageDownloadFailureStillReturnsNotification() async { + ExtensionURLProtocol.reset(error: URLError(.notConnectedToInternet)) + let content = crmContent() + content.userInfo["image_url"] = "https://example.com/image.png" + let enriched = expectation(description: "Notification returned without image") + XCTAssertTrue(handler.handle(request: request(content), content: content) { result in + XCTAssertEqual(result.body, "Message") + XCTAssertTrue(result.attachments.isEmpty) + enriched.fulfill() + }) + await fulfillment(of: [enriched], timeout: 3) + XCTAssertEqual(ExtensionURLProtocol.requests.compactMap(\.httpMethod).sorted(), ["GET", "POST"]) + } + + func testButtonsKeepActionIdentifiersAndURLs() async throws { + try XCTSkipUnless(Bundle.main.bundleURL.pathExtension == "app", "Requires an iOS application test host") + let content = crmContent() + content.userInfo["buttons"] = #"[{"title":"Buy","value":"app://buy"}]"# + let enriched = expectation(description: "Buttons added") + XCTAssertTrue(handler.handle(request: request(content), content: content) { result in + XCTAssertEqual(result.categoryIdentifier, "app_1") + XCTAssertEqual(result.userInfo["push_button_action_urls"] as? [String: String], ["app_1_0": "app://buy"]) + enriched.fulfill() + }) + await fulfillment(of: [enriched], timeout: 3) + let categories = await UNUserNotificationCenter.current().notificationCategories() + let category = try XCTUnwrap(categories.first { $0.identifier == "app_1" }) + XCTAssertEqual(category.actions.map(\.identifier), ["app_1_0"]) + UNUserNotificationCenter.current().setNotificationCategories(Set(categories.filter { $0.identifier != "app_1" })) + XCTAssertEqual(ExtensionURLProtocol.requests.count, 1) + } + + private func crmContent() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.body = "Message" + content.userInfo = ["r_group_push": true, "push_id": "app_1"] + return content + } + + private func request(_ content: UNNotificationContent) -> UNNotificationRequest { + UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + } + + private func bodyData(_ request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var result = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + result.append(buffer, count: count) + } + return result + } +} + +private final class ExtensionURLProtocol: URLProtocol { + private static let lock = NSLock() + private static var capturedRequests: [URLRequest] = [] + private static var responseError: Error? + + static var requests: [URLRequest] { + lock.lock() + defer { lock.unlock() } + return capturedRequests + } + + static func reset(error: Error? = nil) { + lock.lock() + defer { lock.unlock() } + capturedRequests = [] + responseError = error + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.capturedRequests.append(request) + let error = Self.responseError + Self.lock.unlock() + if let error { + client?.urlProtocol(self, didFailWithError: error) + } else { + let response = HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + } + } + + override func stopLoading() {} +} diff --git a/Tests/PushNotificationExtensionTests/PushStatusReporterTests.swift b/Tests/PushNotificationExtensionTests/PushStatusReporterTests.swift new file mode 100644 index 0000000..cdf2662 --- /dev/null +++ b/Tests/PushNotificationExtensionTests/PushStatusReporterTests.swift @@ -0,0 +1,375 @@ +import Foundation +import XCTest +@testable import PushNotificationExtension + +final class PushStatusReporterTests: XCTestCase { + func testRetryPreservesEventAndCompletesInitialAttemptOnlyOnce() async throws { + let fixture = makeFixture() + defer { fixture.cleanUp() } + StatusRetryProtocol.reset([.http(503), .http(204)]) + let firstAttempt = expectation(description: "Initial failure returned without waiting for retries") + firstAttempt.assertForOverFulfill = true + fixture.reporter.reportStatus(.clicked, pushId: "push-id", buttonHash: "button-hash", siteToken: "site") { + guard case .failure(let error) = $0 else { return XCTFail("Expected initial failure") } + XCTAssertEqual(error, .httpStatus(503, [])) + firstAttempt.fulfill() + } + await fulfillment(of: [firstAttempt], timeout: 2) + await waitUntil { StatusRetryProtocol.requests.count == 2 && fixture.isQueueEmpty } + let requests = StatusRetryProtocol.requests + let bodies = try requests.map { try JSONDecoder().decode(PushStatusRequestBody.self, from: XCTUnwrap($0.httpBody)) } + XCTAssertEqual(bodies.count, 2) + XCTAssertEqual(bodies.first, bodies.last) + XCTAssertEqual(bodies.first?.buttonHash, "button-hash") + XCTAssertEqual(bodies.first?.status, .clicked) + XCTAssertEqual(bodies.first?.pushId, "push-id") + XCTAssertTrue(requests.allSatisfy { $0.value(forHTTPHeaderField: "X-Site-Token") == "site" }) + } + + func testTransientFailuresAreRetried() async { + for reply in [StatusRetryProtocol.Reply.offline, .nonHTTP, .http(408), .http(425), .http(429), .http(500), .http(599)] { + let fixture = makeFixture() + StatusRetryProtocol.reset([reply, .http(200)]) + let failed = expectation(description: "First attempt failed") + fixture.reporter.reportStatus(.delivered, pushId: "push", siteToken: "site") { result in + guard case .failure = result else { return XCTFail("Expected first failure") } + failed.fulfill() + } + await fulfillment(of: [failed], timeout: 2) + await waitUntil { StatusRetryProtocol.requests.count == 2 && fixture.isQueueEmpty } + fixture.cleanUp() + } + } + + func testPermanentClientErrorsAreNotQueued() async { + for status in [400, 401, 403, 404, 422] { + let fixture = makeFixture() + StatusRetryProtocol.reset([.http(status)]) + let failed = expectation(description: "Permanent failure") + fixture.reporter.reportStatus(.swiped, pushId: "push", siteToken: "site") { result in + guard case .failure(let error) = result else { return XCTFail("Expected permanent failure") } + XCTAssertEqual(error, .httpStatus(status, [])) + failed.fulfill() + } + await fulfillment(of: [failed], timeout: 2) + XCTAssertTrue(fixture.isQueueEmpty) + XCTAssertEqual(StatusRetryProtocol.requests.count, 1) + fixture.cleanUp() + } + } + + func testRetryAfterIsRespected() async { + let fixture = makeFixture() + defer { fixture.cleanUp() } + StatusRetryProtocol.reset([.http(429, retryAfter: "1"), .http(204)]) + let failed = expectation(description: "Rate limit returned") + fixture.reporter.reportStatus(.delivered, pushId: "push", siteToken: "site") { _ in failed.fulfill() } + await fulfillment(of: [failed], timeout: 2) + let started = Date() + await waitUntil { StatusRetryProtocol.requests.count == 2 && fixture.isQueueEmpty } + XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(started), 0.9) + } + + func testPendingFailureIsRestoredAfterReporterIsRecreated() async throws { + let databaseURL = temporaryURL() + let storage = PushStatusRetryStorage(databaseURL: databaseURL) + let session = makeSession() + defer { + session.invalidateAndCancel() + } + StatusRetryProtocol.reset([.offline, .http(204)]) + var queue: PushStatusRetryQueue? = PushStatusRetryQueue( + storage: storage, baseDelay: 1, monitorsNetwork: false + ) + weak var previousQueue = queue + let customURL = URL(string: "https://consultant.example.com/api/client/v1")! + var reporter: PushStatusReporter? = .init( + session: session, retryQueue: try XCTUnwrap(queue), clientAPIEndpointURL: customURL + ) + let failed = expectation(description: "Failed event saved") + reporter?.reportStatus(.clicked, pushId: "saved-push", buttonHash: "hash", siteToken: "saved-site") { _ in + failed.fulfill() + } + await fulfillment(of: [failed], timeout: 2) + let entries = try storage.loadPending() + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.attempts, 1) + reporter = nil + queue = nil + await waitUntil { previousQueue == nil } + + let restoredStorage = PushStatusRetryStorage(databaseURL: databaseURL) + let restored = PushStatusRetryQueue(storage: restoredStorage, baseDelay: 0.01, monitorsNetwork: false) + let newReporter = PushStatusReporter(session: session, retryQueue: restored) + defer { withExtendedLifetime(newReporter) {} } + await waitUntil { StatusRetryProtocol.requests.count == 2 } + let request = try XCTUnwrap(StatusRetryProtocol.requests.last) + XCTAssertEqual(request.url, customURL.appendingPathComponent("push-tokens/set-status")) + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Site-Token"), "saved-site") + let body = try JSONDecoder().decode(PushStatusRequestBody.self, from: XCTUnwrap(request.httpBody)) + XCTAssertEqual(body.pushId, "saved-push") + XCTAssertEqual(body.buttonHash, "hash") + await waitUntil { + (try? restoredStorage.loadPending().isEmpty) == true + } + } + + func testRestoredAttemptBudgetAllowsOnlyTheRemainingAttempt() async throws { + let databaseURL = temporaryURL() + let storage = PushStatusRetryStorage(databaseURL: databaseURL) + try storage.save(.init( + id: UUID(), body: .init(changedAt: 100, pushId: "push", status: .delivered, buttonHash: nil), + siteToken: "site", clientAPIEndpointURL: URL(string: "https://c.retailcrm.tech/api/client/v1")!, + attempts: 4, nextAttemptAt: Date() + )) + let session = makeSession() + defer { session.invalidateAndCancel() } + StatusRetryProtocol.reset([.offline]) + let queue = PushStatusRetryQueue(storage: storage, baseDelay: 0.01, monitorsNetwork: false) + let reporter = PushStatusReporter(session: session, retryQueue: queue) + defer { withExtendedLifetime(reporter) {} } + await waitUntil { StatusRetryProtocol.requests.count == 1 && (try? storage.loadPending().isEmpty) == true } + let body = try JSONDecoder().decode(PushStatusRequestBody.self, from: XCTUnwrap(StatusRetryProtocol.requests.first?.httpBody)) + XCTAssertEqual(body.changedAt, 100) + } + + func testRemovingOneStatusPreservesOtherStoredEntries() async throws { + let session = makeSession() + let databaseURL = temporaryURL() + let storage = PushStatusRetryStorage(databaseURL: databaseURL) + let queue = PushStatusRetryQueue(storage: storage, baseDelay: 3_600, monitorsNetwork: false) + let reporter = PushStatusReporter(session: session, retryQueue: queue) + defer { + session.invalidateAndCancel() + } + StatusRetryProtocol.reset([.http(500)]) + let saved = expectation(description: "Both statuses saved") + saved.expectedFulfillmentCount = 2 + reporter.reportStatus(.delivered, pushId: "first", siteToken: "site") { _ in saved.fulfill() } + reporter.reportStatus(.clicked, pushId: "second", buttonHash: "hash", siteToken: "site") { _ in saved.fulfill() } + await fulfillment(of: [saved], timeout: 2) + StatusRetryProtocol.reset([.http(204)]) + let removed = expectation(description: "First status removed") + reporter.reportStatus(.delivered, pushId: "first", siteToken: "site") { _ in removed.fulfill() } + await fulfillment(of: [removed], timeout: 2) + + let entries = try storage.loadPending() + XCTAssertEqual(entries.count, 1) + let body = try XCTUnwrap(entries.first?.body) + XCTAssertEqual(body.pushId, "second") + XCTAssertEqual(body.buttonHash, "hash") + XCTAssertEqual(entries.first?.attempts, 1) + } + + func testObserverReportsRetryLifecycleWithStableEventIdentity() async throws { + let fixture = makeFixture() + defer { fixture.cleanUp() } + let observation = PushStatusRetryObservation(siteToken: "site", queue: fixture.queue) + let updates = RetryUpdatesRecorder() + observation.onUpdate = { updates.append($0) } + defer { withExtendedLifetime(observation) {} } + StatusRetryProtocol.reset([.http(503), .http(429), .http(204)]) + fixture.reporter.reportStatus(.clicked, pushId: "push", buttonHash: "hash", siteToken: "site") + await waitUntil { updates.values.count == 6 } + let values = updates.values + guard values.count == 6, + case .queued(let event, .httpStatus(503, [])) = values[0], + case .retryScheduled(let scheduled, 2, let firstDate, .httpStatus(503, [])) = values[1], + case .sending(let sending, 2) = values[2], + case .retryScheduled(let rescheduled, 3, let secondDate, .httpStatus(429, [])) = values[3], + case .sending(let sendingAgain, 3) = values[4], + case .delivered(let delivered) = values[5] else { + return XCTFail("Unexpected retry lifecycle: \(values)") + } + XCTAssertEqual([scheduled, sending, rescheduled, sendingAgain, delivered], Array(repeating: event, count: 5)) + XCTAssertEqual(event.pushID, "push") + XCTAssertEqual(event.status, .clicked) + XCTAssertEqual(event.buttonHash, "hash") + XCTAssertLessThan(firstDate, secondDate) + } + + func testObserverReportsExhaustedAttempts() async { + for reply in [StatusRetryProtocol.Reply.http(503), .offline] { + let fixture = makeFixture() + defer { fixture.cleanUp() } + let observation = PushStatusRetryObservation(siteToken: "site", queue: fixture.queue) + let updates = RetryUpdatesRecorder() + observation.onUpdate = { updates.append($0) } + defer { withExtendedLifetime(observation) {} } + StatusRetryProtocol.reset([reply]) + let failed = expectation(description: "Initial attempt failed: \(reply)") + fixture.reporter.reportStatus(.delivered, pushId: "push", siteToken: "site") { _ in failed.fulfill() } + await fulfillment(of: [failed], timeout: 2) + await waitUntil { + if case .dropped(_, 5, _)? = updates.values.last { return true } + return false + } + if case .http = reply { + guard case .dropped(_, 5, .httpStatus(503, []))? = updates.values.last else { + return XCTFail("Expected final HTTP error") + } + } + XCTAssertEqual(StatusRetryProtocol.requests.count, 5) + XCTAssertTrue(fixture.isQueueEmpty) + try? await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(StatusRetryProtocol.requests.count, 5, "No sixth attempt: \(reply)") + } + } + + func testObserverReportsPermanentRetryRejection() async { + let fixture = makeFixture() + defer { fixture.cleanUp() } + let observation = PushStatusRetryObservation(siteToken: "site", queue: fixture.queue) + let updates = RetryUpdatesRecorder() + observation.onUpdate = { updates.append($0) } + defer { withExtendedLifetime(observation) {} } + StatusRetryProtocol.reset([.http(500), .http(403)]) + fixture.reporter.reportStatus(.delivered, pushId: "push", siteToken: "site") + await waitUntil { + if case .rejected(_, .httpStatus(403, []))? = updates.values.last { return true } + return false + } + XCTAssertEqual(StatusRetryProtocol.requests.count, 2) + XCTAssertTrue(fixture.isQueueEmpty) + } + + func testLateObserversReceiveSnapshotAndUnsubscribeIndependently() async { + let fixture = makeFixture(baseDelay: 3_600) + defer { fixture.cleanUp() } + StatusRetryProtocol.reset([.http(500)]) + let queued = expectation(description: "Status queued") + fixture.reporter.reportStatus(.delivered, pushId: "push", siteToken: "site") { _ in queued.fulfill() } + await fulfillment(of: [queued], timeout: 2) + let first = PushStatusRetryObservation(siteToken: "site", queue: fixture.queue) + let second = PushStatusRetryObservation(siteToken: "site", queue: fixture.queue) + let otherSite = PushStatusRetryObservation(siteToken: "other", queue: fixture.queue) + let firstUpdates = RetryUpdatesRecorder() + let secondUpdates = RetryUpdatesRecorder() + let otherUpdates = RetryUpdatesRecorder() + first.onUpdate = { firstUpdates.append($0) } + second.onUpdate = { secondUpdates.append($0) } + otherSite.onUpdate = { otherUpdates.append($0) } + defer { withExtendedLifetime((first, second, otherSite)) {} } + await waitUntil { firstUpdates.values.count == 1 && secondUpdates.values.count == 1 } + guard case .pending(let event, 1, let date)? = firstUpdates.values.first else { + return XCTFail("Expected snapshot of pending status") + } + XCTAssertEqual(event.pushID, "push") + XCTAssertGreaterThan(date, Date()) + first.onUpdate = nil + StatusRetryProtocol.setReplies([.http(204)]) + let succeeded = expectation(description: "Duplicate report succeeded") + fixture.reporter.reportStatus(.delivered, pushId: "push", siteToken: "site") { result in + guard case .success = result else { return XCTFail("Expected successful duplicate report") } + succeeded.fulfill() + } + await fulfillment(of: [succeeded], timeout: 2) + await waitUntil { secondUpdates.values.count == 2 } + XCTAssertEqual(StatusRetryProtocol.requests.count, 2) + XCTAssertTrue(fixture.isQueueEmpty) + XCTAssertEqual(secondUpdates.values.last, .delivered(event)) + XCTAssertEqual(firstUpdates.values.count, 1) + XCTAssertTrue(otherUpdates.values.isEmpty) + } + + private func makeFixture(baseDelay: TimeInterval = 0.01) -> Fixture { + let session = makeSession() + let databaseURL = temporaryURL() + let storage = PushStatusRetryStorage(databaseURL: databaseURL) + let queue = PushStatusRetryQueue(storage: storage, baseDelay: baseDelay, monitorsNetwork: false) + return Fixture(session: session, reporter: .init(session: session, retryQueue: queue), queue: queue, + storage: storage, databaseURL: databaseURL) + } + + private func temporaryURL() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("PushStatusReporterTests-\(UUID().uuidString).sqlite") + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StatusRetryProtocol.self] + return URLSession(configuration: config) + } + + private func waitUntil(file: StaticString = #filePath, line: UInt = #line, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(3) + while !condition(), Date() < deadline { try? await Task.sleep(nanoseconds: 2_000_000) } + XCTAssertTrue(condition(), file: file, line: line) + } + + private struct Fixture { + let session: URLSession + let reporter: PushStatusReporter + let queue: PushStatusRetryQueue + let storage: PushStatusRetryStorage + let databaseURL: URL + var isQueueEmpty: Bool { + (try? storage.loadPending().isEmpty) == true + } + func cleanUp() { + session.invalidateAndCancel() + } + } +} + +private final class RetryUpdatesRecorder { + private let lock = NSLock() + private var stored: [PushStatusRetryUpdate] = [] + var values: [PushStatusRetryUpdate] { lock.withLock { stored } } + func append(_ update: PushStatusRetryUpdate) { lock.withLock { stored.append(update) } } +} + +private final class StatusRetryProtocol: URLProtocol { + enum Reply { + case http(Int, retryAfter: String? = nil) + case offline + case nonHTTP + } + private static let lock = NSLock() + private static var replies: [Reply] = [] + private static var captured: [URLRequest] = [] + static var requests: [URLRequest] { lock.withLock { captured } } + static func reset(_ replies: [Reply]) { + lock.withLock { self.replies = replies; captured = [] } + } + static func setReplies(_ replies: [Reply]) { lock.withLock { self.replies = replies } } + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + var captured = request + if captured.httpBody == nil, let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + captured.httpBody = data + } + let reply = Self.lock.withLock { + Self.captured.append(captured) + return Self.replies.count > 1 ? Self.replies.removeFirst() : Self.replies.first ?? .http(204) + } + let response: URLResponse + switch reply { + case .offline: + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + return + case .nonHTTP: + response = URLResponse(url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) + case .http(let status, let retryAfter): + response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: nil, + headerFields: retryAfter.map { ["Retry-After": $0] } + )! + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/Tests/PushNotificationExtensionTests/PushStatusRetryStorageTests.swift b/Tests/PushNotificationExtensionTests/PushStatusRetryStorageTests.swift new file mode 100644 index 0000000..0bc6072 --- /dev/null +++ b/Tests/PushNotificationExtensionTests/PushStatusRetryStorageTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import PushNotificationExtension + +final class PushStatusRetryStorageTests: XCTestCase { + func testRetryStateSurvivesReopeningAndIndividualUpdatesPreserveOtherEntries() throws { + let url = temporaryURL() + var first = entry(pushID: "first") + let second = entry(pushID: "second") + do { + let storage = PushStatusRetryStorage(databaseURL: url) + try storage.save(first) + try storage.save(second) + first.attempts = 4 + first.nextAttemptAt = Date(timeIntervalSince1970: 5_000) + try storage.save(first) + } + let reopened = PushStatusRetryStorage(databaseURL: url) + XCTAssertEqual(try reopened.loadPending(), [second, first]) + try reopened.remove(first.id) + XCTAssertEqual(try reopened.loadPending(), [second]) + } + + func testDuplicateStatusDoesNotReplaceOriginalTimeOrAttemptBudget() throws { + let url = temporaryURL() + let storage = PushStatusRetryStorage(databaseURL: url) + let first = entry(pushID: "push") + var duplicate = entry(pushID: "push") + duplicate.attempts = 3 + XCTAssertNotEqual(first.id, duplicate.id) + try storage.save(first) + XCTAssertThrowsError(try storage.save(duplicate)) + XCTAssertEqual(try storage.loadPending(), [first]) + try storage.save(entry(pushID: "push", site: "other")) + try storage.save(entry(pushID: "push", endpoint: "https://other.example.com/api/client/v1")) + try storage.save(entry(pushID: "push", buttonHash: "")) + XCTAssertEqual(try storage.loadPending().count, 4) + } + + func testSeparateContainerPathsHaveIndependentQueues() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let app = PushStatusRetryStorage(databaseURL: root.appendingPathComponent("app/crm_push_status_retries.sqlite")) + let extensionStorage = PushStatusRetryStorage(databaseURL: root.appendingPathComponent("extension/crm_push_status_retries.sqlite")) + let appEntry = entry(pushID: "push") + let extensionEntry = entry(pushID: "push") + try app.save(appEntry) + try extensionStorage.save(extensionEntry) + try app.remove(appEntry.id) + XCTAssertTrue(try app.loadPending().isEmpty) + XCTAssertEqual(try extensionStorage.loadPending(), [extensionEntry]) + } + + private func entry( + pushID: String, site: String = "site", endpoint: String = "https://c.retailcrm.tech/api/client/v1", buttonHash: String? = nil + ) -> PendingPushStatus { + .init(id: UUID(), body: .init(changedAt: 100, pushId: pushID, status: .clicked, buttonHash: buttonHash), + siteToken: site, clientAPIEndpointURL: URL(string: endpoint)!, attempts: 1, + nextAttemptAt: Date(timeIntervalSince1970: 1_000)) + } + + private func temporaryURL() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("PushStatusRetryStorageTests-\(UUID().uuidString).sqlite") + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} diff --git a/Tests/PushServiceTests/PushAutomaticRegistrationTests.swift b/Tests/PushServiceTests/PushAutomaticRegistrationTests.swift new file mode 100644 index 0000000..984c259 --- /dev/null +++ b/Tests/PushServiceTests/PushAutomaticRegistrationTests.swift @@ -0,0 +1,396 @@ +import FirebaseCore +import Foundation +import XCTest +import UserNotifications +@testable import Core +@testable import PushService + +@MainActor +final class PushAutomaticRegistrationTests: XCTestCase { + private var databaseURL: URL! + private var originalFirebaseOptions: FirebaseOptions? + private var configuredFirebase = false + + override func setUp() async throws { + // UNUserNotificationCenter requires an application-hosted test process. + try XCTSkipUnless(Bundle.main.bundleURL.pathExtension == "app", "Requires an iOS application test host") + originalFirebaseOptions = FirebaseApp.app()?.options + await configureFirebase(project: "test-firebase") + configuredFirebase = true + databaseURL = FileManager.default.temporaryDirectory.appendingPathComponent("PushAutomaticTests-\(UUID().uuidString).sqlite") + PushRegistrationURLProtocol.reset() + } + + override func tearDown() async throws { + if let databaseURL { try? FileManager.default.removeItem(at: databaseURL) } + if configuredFirebase { + await deleteFirebaseApp() + if let originalFirebaseOptions { FirebaseApp.configure(options: originalFirebaseOptions) } + } + } + + private func deleteFirebaseApp() async { + guard let app = FirebaseApp.app() else { return } + await withCheckedContinuation { continuation in + app.delete { _ in continuation.resume() } + } + } + + private func configureFirebase(project: String) async { + await deleteFirebaseApp() + let options = FirebaseOptions(googleAppID: "1:123456789:ios:0123456789abcdef", gcmSenderID: "123456789") + options.apiKey = "A" + String(repeating: "0", count: 38) + options.projectID = project + FirebaseApp.configure(options: options) + } + + private func service(customer: Int64 = 1) -> PushService { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [PushRegistrationURLProtocol.self] + return PushService( + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "test-site"), + customerIdentity: .init(customerId: customer, siteCustomerId: nil, siteCode: nil)), + tokenRegistrator: .init(session: URLSession(configuration: configuration)), + pushHandler: PushHandler(siteToken: "test-site", retryQueue: isolatedRetryQueue()), + registrationStorage: .init(databaseURL: databaseURL) + ) + } + + private func connect(_ service: PushService) throws { + let response = try JSONDecoder().decode(ConnectResponse.self, from: Data( + #"{"user":{"token":"user"},"session":{"id":1}}"#.utf8 + )) + service.sessionDidConnect(credentials: .init(userToken: "user", sessionID: "1"), + pageContext: .init(), user: .init(), response: response, customerIdentity: nil) + } + + private func settle() async { + // Allow lifecycle tasks, URLProtocol delivery and main-actor callbacks to settle. + try? await Task.sleep(nanoseconds: 100_000_000) + } + + func testStorageFailureIsReportedWithoutStartingRegistration() async throws { + let blocker = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Data().write(to: blocker) + defer { try? FileManager.default.removeItem(at: blocker) } + databaseURL = blocker.appendingPathComponent("registration.sqlite") + let service = service() + service.setToken(.fcm("A")) + try connect(service) + do { + _ = try await service.registerNotificationCategories(["orders"]) + XCTFail("Expected a storage failure") + } catch PushServiceError.storage(let message) { + XCTAssertFalse(message.isEmpty) + } + XCTAssertEqual(PushRegistrationURLProtocol.count, 0) + } + + func testRegistrationWaitsForTokenAndSession() async throws { + let service = service() + let applied = expectation(description: "Registration after readiness") + service.registerNotificationCategories(["orders"]) { result in + XCTAssertEqual(try? result.get(), .fcm("A")) + applied.fulfill() + } + await settle() + XCTAssertEqual(PushRegistrationURLProtocol.count, 0) + service.setToken(.fcm("A")) + await settle() + XCTAssertEqual(PushRegistrationURLProtocol.count, 0) + try connect(service) + await fulfillment(of: [applied], timeout: 2) + XCTAssertEqual(PushRegistrationURLProtocol.lastCategories, ["orders"]) + } + + func testNoRequestUntilIntegratorSuppliesListIncludingEmpty() async throws { + let service = service() + service.setToken(.fcm("A")) + try connect(service) + await settle() + XCTAssertEqual(PushRegistrationURLProtocol.count, 0) + let token = try await service.registerNotificationCategories([]) + XCTAssertEqual(token, .fcm("A")) + XCTAssertEqual(PushRegistrationURLProtocol.lastCategories, []) + } + + func testFullListsReplaceCategoriesAndEmptyUnsubscribes() async throws { + let service = service() + service.setToken(.fcm("A")) + try connect(service) + _ = try await service.registerNotificationCategories(["orders", "news"]) + _ = try await service.registerNotificationCategories(["news"]) + XCTAssertEqual(PushRegistrationURLProtocol.lastCategories, ["news"]) + _ = try await service.registerNotificationCategories([]) + XCTAssertEqual(PushRegistrationURLProtocol.lastCategories, []) + _ = try await service.registerNotificationCategories([]) + XCTAssertEqual(PushRegistrationURLProtocol.count, 3) + } + + func testCategoryOrderAndDuplicatesDoNotCauseRequests() async throws { + let service = service() + service.setToken(.fcm("A")) + try connect(service) + _ = try await service.registerNotificationCategories(["orders", "news"]) + _ = try await service.registerNotificationCategories(["news", "orders", "news"]) + XCTAssertEqual(PushRegistrationURLProtocol.count, 1) + } + + func testRestartSkipsConfirmedRegistrationAndUnsubscription() async throws { + for categories in [["orders"], []] { + let first = service() + first.setToken(.fcm("A")) + try connect(first) + _ = try await first.registerNotificationCategories(categories) + let beforeRestart = PushRegistrationURLProtocol.count + let restarted = service() + restarted.setToken(.fcm("A")) + try connect(restarted) + _ = try await restarted.registerNotificationCategories(categories) + XCTAssertEqual(PushRegistrationURLProtocol.count, beforeRestart) + } + } + + func testRestartRemovesOldSystemCategoriesAndPreservesUnrelatedCategories() async throws { + let oldID = "old-\(UUID().uuidString)" + let retainedID = "retained-\(UUID().uuidString)" + let newID = "new-\(UUID().uuidString)" + let unrelatedID = "unrelated-\(UUID().uuidString)" + await addSystemCategory(unrelatedID) + let first = service() + first.setToken(.fcm("A")) + try connect(first) + _ = try await first.registerNotificationCategories([oldID, retainedID]) + await waitForSystemCategories { $0.contains(oldID) && $0.contains(retainedID) && $0.contains(unrelatedID) } + + let restarted = service() + restarted.setToken(.fcm("A")) + try connect(restarted) + _ = try await restarted.registerNotificationCategories([retainedID, newID]) + await waitForSystemCategories { + !$0.contains(oldID) && $0.contains(retainedID) && $0.contains(newID) && $0.contains(unrelatedID) + } + XCTAssertEqual(PushRegistrationURLProtocol.count, 2) + _ = try await restarted.registerNotificationCategories([]) + await waitForSystemCategories { + !$0.contains(oldID) && !$0.contains(retainedID) && !$0.contains(newID) && $0.contains(unrelatedID) + } + await removeSystemCategory(unrelatedID) + } + + func testFailedListIsRetriedAfterRestart() async throws { + let service = service() + service.setToken(.fcm("A")) + try connect(service) + _ = try await service.registerNotificationCategories(["orders"]) + PushRegistrationURLProtocol.statusCode = 500 + do { + _ = try await service.registerNotificationCategories(["news"]) + XCTFail("Expected server failure") + } catch { } + PushRegistrationURLProtocol.statusCode = 204 + let restarted = self.service() + restarted.setToken(.fcm("A")) + try connect(restarted) + _ = try await restarted.registerNotificationCategories(["news"]) + _ = try await restarted.registerNotificationCategories(["news"]) + XCTAssertEqual(PushRegistrationURLProtocol.count, 3) + XCTAssertEqual(PushRegistrationURLProtocol.lastCategories, ["news"]) + } + + func testRestartRemovesUnconfirmedSystemCategories() async throws { + let oldID = "confirmed-\(UUID().uuidString)" + let failedID = "failed-\(UUID().uuidString)" + let newID = "new-\(UUID().uuidString)" + let first = service() + first.setToken(.fcm("A")) + try connect(first) + _ = try await first.registerNotificationCategories([oldID]) + PushRegistrationURLProtocol.statusCode = 500 + do { + _ = try await first.registerNotificationCategories([failedID]) + XCTFail("Expected server failure") + } catch { } + await waitForSystemCategories { !$0.contains(oldID) && $0.contains(failedID) } + PushRegistrationURLProtocol.statusCode = 204 + let restarted = service() + restarted.setToken(.fcm("A")) + try connect(restarted) + _ = try await restarted.registerNotificationCategories([newID]) + await waitForSystemCategories { !$0.contains(oldID) && !$0.contains(failedID) && $0.contains(newID) } + _ = try await restarted.registerNotificationCategories([]) + } + + func testNewListSupersedesPendingCallBeforeReadiness() async throws { + let service = service() + let superseded = expectation(description: "Old list superseded") + service.registerNotificationCategories(["orders"]) { result in + guard case let .failure(error) = result else { + XCTFail("Expected superseded result") + superseded.fulfill() + return + } + XCTAssertEqual(error, .operationSuperseded) + superseded.fulfill() + } + let latest = expectation(description: "Latest list applied") + service.registerNotificationCategories(["news"]) { result in + XCTAssertEqual(try? result.get(), .fcm("A")) + latest.fulfill() + } + service.setToken(.fcm("A")) + try connect(service) + await fulfillment(of: [superseded, latest], timeout: 2) + XCTAssertEqual(PushRegistrationURLProtocol.count, 1) + XCTAssertEqual(PushRegistrationURLProtocol.lastCategories, ["news"]) + } + + func testListChangedDuringRequestIsAppliedAfterCurrentRequest() async throws { + let service = service() + let started = expectation(description: "First request started") + PushRegistrationURLProtocol.responseDelay = 0.1 + PushRegistrationURLProtocol.onRequest = { started.fulfill() } + let firstCompleted = expectation(description: "First request completed") + service.registerNotificationCategories(["orders"]) { result in + XCTAssertNotNil(try? result.get()) + firstCompleted.fulfill() + } + service.setToken(.fcm("A")) + try connect(service) + await fulfillment(of: [started], timeout: 2) + PushRegistrationURLProtocol.onRequest = nil + let lastCompleted = expectation(description: "Changed list completed") + service.registerNotificationCategories(["news"]) { result in + XCTAssertNotNil(try? result.get()) + lastCompleted.fulfill() + } + await fulfillment(of: [firstCompleted, lastCompleted], timeout: 2) + XCTAssertEqual(PushRegistrationURLProtocol.count, 2) + XCTAssertEqual(PushRegistrationURLProtocol.lastCategories, ["news"]) + } + + func testCustomerProjectAndTokenTypeChangesCauseRequests() async throws { + let configurations: [(Int64, String, PushToken)] = [ + (1, "firebase", .fcm("A")), (2, "firebase", .fcm("A")), + (2, "other", .fcm("A")), (2, "other", .fid("A")) + ] + for (customer, project, token) in configurations { + await configureFirebase(project: project) + let service = service(customer: customer) + service.setToken(token) + try connect(service) + _ = try await service.registerNotificationCategories(["orders"]) + } + XCTAssertEqual(PushRegistrationURLProtocol.count, configurations.count) + } + + func testGlobalCallbackReportsRotatedTokenButNotCachedList() async throws { + let service = service() + service.setToken(.fcm("A")) + try connect(service) + _ = try await service.registerNotificationCategories(["orders"]) + let updated = expectation(description: "Updated token reported") + service.onPushRegistrationResult = { result in + XCTAssertEqual(try? result.get(), .fcm("B")) + updated.fulfill() + } + service.setToken(.fcm("B")) + await fulfillment(of: [updated], timeout: 2) + let cached = expectation(description: "Cached list emits no event") + cached.isInverted = true + service.onPushRegistrationResult = { _ in cached.fulfill() } + _ = try await service.registerNotificationCategories(["orders"]) + await fulfillment(of: [cached], timeout: 0.1) + XCTAssertEqual(PushRegistrationURLProtocol.count, 2) + } + + private func addSystemCategory(_ identifier: String) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + UNUserNotificationCenter.current().getNotificationCategories { categories in + var updated = categories + updated.insert(UNNotificationCategory(identifier: identifier, actions: [], intentIdentifiers: [], options: [])) + UNUserNotificationCenter.current().setNotificationCategories(updated) + continuation.resume() + } + } + await waitForSystemCategories { $0.contains(identifier) } + } + + private func removeSystemCategory(_ identifier: String) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + UNUserNotificationCenter.current().getNotificationCategories { categories in + UNUserNotificationCenter.current().setNotificationCategories(categories.filter { $0.identifier != identifier }) + continuation.resume() + } + } + } + + private func waitForSystemCategories(_ predicate: (Set) -> Bool) async { + for _ in 0..<100 { + let identifiers: Set = await withCheckedContinuation { continuation in + UNUserNotificationCenter.current().getNotificationCategories { categories in + continuation.resume(returning: Set(categories.map(\.identifier))) + } + } + if predicate(identifiers) { return } + try? await Task.sleep(nanoseconds: 10_000_000) + } + XCTFail("Timed out waiting for system categories") + } +} + +final class PushRegistrationURLProtocol: URLProtocol { + private static let lock = NSLock() + private static var bodies: [[String: Any]] = [] + static var count: Int { lock.withLock { bodies.count } } + static var lastCategories: [String]? { lock.withLock { bodies.last?["categories"] as? [String] } } + private static var responseStatus = 204 + static var statusCode: Int { + get { lock.withLock { responseStatus } } + set { lock.withLock { responseStatus = newValue } } + } + private static var delay: TimeInterval = 0 + private static var requestObserver: (() -> Void)? + static var responseDelay: TimeInterval { + get { lock.withLock { delay } } + set { lock.withLock { delay = newValue } } + } + static var onRequest: (() -> Void)? { + get { lock.withLock { requestObserver } } + set { lock.withLock { requestObserver = newValue } } + } + static func reset() { lock.withLock { bodies = []; responseStatus = 204; delay = 0; requestObserver = nil } } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + var data = request.httpBody ?? Data() + if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(contentsOf: buffer.prefix(count)) + } + } + let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] + let (status, delay, observer) = Self.lock.withLock { () -> (Int, TimeInterval, (() -> Void)?) in + Self.bodies.append(body) + return (Self.responseStatus, Self.delay, Self.requestObserver) + } + observer?() + let respond = { [self] in + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) + } + if delay > 0 { + DispatchQueue.global().asyncAfter(deadline: .now() + delay, execute: respond) + } else { + respond() + } + } + override func stopLoading() {} +} diff --git a/Tests/PushServiceTests/PushNetworkSessionTests.swift b/Tests/PushServiceTests/PushNetworkSessionTests.swift new file mode 100644 index 0000000..b0cab48 --- /dev/null +++ b/Tests/PushServiceTests/PushNetworkSessionTests.swift @@ -0,0 +1,87 @@ +import Foundation +import UserNotifications +import XCTest +@testable import Core +@testable import PushService +@testable import PushNotificationExtension + +final class PushNetworkSessionTests: XCTestCase { + func testExtensionSessionCompletesAndExposesStatusRetryUpdates() async { + let session = makeSession() + defer { session.invalidateAndCancel() } + let siteToken = UUID().uuidString + let databaseURL = FileManager.default.temporaryDirectory.appendingPathComponent("PushNetworkSessionTests-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: databaseURL) } + let handler = PushNotificationHandler( + siteToken: siteToken, session: session, + retryQueue: PushStatusRetryQueue(storage: .init(databaseURL: databaseURL), baseDelay: 3_600, monitorsNetwork: false) + ) + let content = makeContent() + let request = UNNotificationRequest(identifier: "extension", content: content, trigger: nil) + let enriched = expectation(description: "Delivery and image requests finished") + let queued = expectation(description: "Extension exposes retry updates") + handler.onStatusRetryUpdate = { update in + if case .queued(let event, _) = update { + XCTAssertEqual(event.pushID, "app_1") + XCTAssertEqual(event.status, .delivered) + queued.fulfill() + } + } + XCTAssertTrue(handler.handle(request: request, content: content) { _ in enriched.fulfill() }) + await fulfillment(of: [enriched, queued], timeout: 3) + } + + func testPushServiceExposesStatusRetryUpdates() async { + let session = makeSession() + defer { session.invalidateAndCancel() } + let databaseURL = FileManager.default.temporaryDirectory.appendingPathComponent("PushNetworkSessionTests-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: databaseURL) } + let queue = PushStatusRetryQueue(storage: .init(databaseURL: databaseURL), baseDelay: 3_600, monitorsNetwork: false) + let handler = PushHandler(siteToken: "site", session: session, retryQueue: queue) + let service = PushService( + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "site")), + pushHandler: handler + ) + let queued = expectation(description: "PushService exposes queued status") + service.onStatusRetryUpdate = { update in + if case .queued(let event, _) = update { + XCTAssertEqual(event.pushID, "app_1") + XCTAssertEqual(event.status, .delivered) + queued.fulfill() + } + } + XCTAssertNotNil(service.onStatusRetryUpdate) + let completed = expectation(description: "First attempt completed") + XCTAssertTrue(handler.handleSilentNotification([ + "r_group_push": true, "push_id": "app_1", "aps": ["content-available": 1] + ]) { _ in completed.fulfill() }) + await fulfillment(of: [queued, completed], timeout: 3) + service.onStatusRetryUpdate = nil + XCTAssertNil(service.onStatusRetryUpdate) + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [PushSessionFailureProtocol.self] + return URLSession(configuration: configuration) + } + + private func makeContent() -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.userInfo = [ + "r_group_push": true, + "push_id": "app_1", + "image_url": "https://example.com/\(UUID().uuidString).png" + ] + return content + } +} + +private final class PushSessionFailureProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } + override func stopLoading() {} +} diff --git a/Tests/PushServiceTests/PushRegistrationStorageTests.swift b/Tests/PushServiceTests/PushRegistrationStorageTests.swift new file mode 100644 index 0000000..9ef366b --- /dev/null +++ b/Tests/PushServiceTests/PushRegistrationStorageTests.swift @@ -0,0 +1,63 @@ +import Foundation +import XCTest +import SQLiteStorage +@testable import PushService + +final class PushRegistrationStorageTests: XCTestCase { + func testRegistrationListsAndFingerprintSurviveReopeningAndStayIsolatedBySite() throws { + let url = temporaryURL() + do { + let storage = PushRegistrationStorage(databaseURL: url) + XCTAssertEqual(try storage.setDesiredCategories(["orders"], site: "first"), []) + try storage.setSystemCategories(["orders"], site: "first") + try storage.confirm(fingerprint: "accepted", categories: ["orders"], site: "first") + try storage.setDesiredCategories(["news"], site: "second") + } + let reopened = PushRegistrationStorage(databaseURL: url) + XCTAssertEqual(try reopened.load(site: "first"), .init( + fingerprint: "accepted", desiredCategories: ["orders"], confirmedCategories: ["orders"], systemCategories: ["orders"] + )) + XCTAssertEqual(try reopened.load(site: "second"), .init(desiredCategories: ["news"])) + XCTAssertEqual(try reopened.load(site: "missing"), .init()) + XCTAssertEqual(try reopened.setDesiredCategories([], site: "first"), ["orders"]) + try reopened.invalidateFingerprint(site: "first") + let state = try reopened.load(site: "first") + XCTAssertNil(state.fingerprint) + XCTAssertEqual(state.desiredCategories, []) + XCTAssertEqual(state.confirmedCategories, ["orders"]) + XCTAssertEqual(state.systemCategories, ["orders"]) + try reopened.confirm(fingerprint: "unsubscribed", categories: [], site: "first") + XCTAssertEqual(try reopened.load(site: "first").confirmedCategories, []) + } + + func testFailedConfirmationDoesNotPartiallyChangeFingerprintOrCategories() throws { + let url = temporaryURL() + let storage = PushRegistrationStorage(databaseURL: url) + try storage.confirm(fingerprint: "old", categories: ["orders"], site: "site") + let database = try SQLiteDatabase(url: url) + try database.withConnection { try $0.execute(""" + CREATE TRIGGER reject_confirmation BEFORE UPDATE ON push_registration + BEGIN SELECT RAISE(FAIL, 'test failure'); END; + """) } + XCTAssertThrowsError(try storage.confirm(fingerprint: "new", categories: ["news"], site: "site")) + XCTAssertEqual(try storage.load(site: "site").fingerprint, "old") + XCTAssertEqual(try storage.load(site: "site").confirmedCategories, ["orders"]) + } + + func testOpeningFailureIsReportedAndCanBeRetried() throws { + let parent = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: parent) } + try Data().write(to: parent) + let storage = PushRegistrationStorage(databaseURL: parent.appendingPathComponent("registration.sqlite")) + XCTAssertThrowsError(try storage.setDesiredCategories(["orders"], site: "site")) + try FileManager.default.removeItem(at: parent) + try storage.setDesiredCategories(["orders"], site: "site") + XCTAssertEqual(try storage.load(site: "site").desiredCategories, ["orders"]) + } + + private func temporaryURL() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("PushRegistrationStorageTests-\(UUID().uuidString).sqlite") + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} diff --git a/Tests/PushServiceTests/PushRetryTestStorage.swift b/Tests/PushServiceTests/PushRetryTestStorage.swift new file mode 100644 index 0000000..f05b217 --- /dev/null +++ b/Tests/PushServiceTests/PushRetryTestStorage.swift @@ -0,0 +1,12 @@ +import Foundation +import XCTest +@testable import PushNotificationExtension + +extension XCTestCase { + /// Each fixture owns its queue, so persisted retries cannot leak into another test or run. + func isolatedRetryQueue() -> PushStatusRetryQueue { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("PushRetryTests-\(UUID().uuidString).sqlite") + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return PushStatusRetryQueue(storage: .init(databaseURL: url), baseDelay: 3_600, monitorsNetwork: false) + } +} diff --git a/Tests/PushServiceTests/PushServicePublicAPITests.swift b/Tests/PushServiceTests/PushServicePublicAPITests.swift new file mode 100644 index 0000000..612490c --- /dev/null +++ b/Tests/PushServiceTests/PushServicePublicAPITests.swift @@ -0,0 +1,497 @@ +import Foundation +import UserNotifications +import XCTest +@testable import PushService +@testable import PushNotificationExtension +@testable import Core + +final class PushServicePublicAPITests: XCTestCase { + + func testPushStatusRequestBodyEncodesBackendPayload() throws { + for hash: String? in [nil, "button-sha256"] { + let body = PushStatusRequestBody(changedAt: 1_808_000_001, pushId: "mobile_123", status: .clicked, buttonHash: hash) + let data = try JSONEncoder().encode(body) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let context = "buttonHash: \(hash ?? "nil")" + XCTAssertEqual((json["changed_at"] as? NSNumber)?.int64Value, 1_808_000_001, context) + XCTAssertEqual(json["push_id"] as? String, "mobile_123", context) + XCTAssertEqual(json["status"] as? String, "clicked", context) + XCTAssertEqual(json["button_hash"] as? String, hash, context) + if hash == nil { XCTAssertNil(json["button_hash"], context) } + XCTAssertNil(json["changedAt"], context) + XCTAssertNil(json["pushId"], context) + XCTAssertNil(json["buttonHash"], context) + } + } + + func testPushMessageParserMapsSDKPushPayload() throws { + let userInfo: [AnyHashable: Any] = [ + "aps": [ + "alert": [ + "title": "Order changed", + "body": "Order text" + ] + ], + "r_group_push": "1", + "r_group_category": " news ", + "push_id": "mobile_123", + "image_url": "https://example.com/image.png", + "click_action": "https://example.com/order", + "ca_orderId": "123", + "ca_source": "push", + "ca_empty": "", + "ca_number": 42, + "ca_": "ignored", + "buttons": #""" + [ + {"title":"Open order","value":"https://example.com/order"}, + {"title":"Support","value":"https://example.com/support"} + ] + """# + ] + + let message = try XCTUnwrap( + PushMessageParser.parse( + userInfo: userInfo, + title: "Order changed", + body: "Order text" + ) + ) + + XCTAssertEqual(message.pushId, "mobile_123") + XCTAssertEqual(message.title, "Order changed") + XCTAssertEqual(message.body, "Order text") + XCTAssertEqual(message.imageURL, URL(string: "https://example.com/image.png")) + XCTAssertEqual(message.clickAction?.stringUrl, "https://example.com/order") + XCTAssertEqual(message.rgCategory, "news") + XCTAssertEqual(message.clickAction?.customAttributes, [ + "orderId": "123", + "source": "push", + "empty": "", + "number": "42" + ]) + XCTAssertEqual(message.buttons, [ + PushButton( + title: "Open order", + value: "https://example.com/order" + ), + PushButton( + title: "Support", + value: "https://example.com/support" + ) + ]) + } + + func testPushMessageParserKeepsActionsWithoutScheme() throws { + for isButton in [false, true] { + var info: [AnyHashable: Any] = ["r_group_push": "1", "push_id": "mobile_123"] + if isButton { info["buttons"] = #"[{"title":"Open order","value":"OPEN_ORDER"}]"# } + else { info["click_action"] = "OPEN_ORDER" } + let message = try XCTUnwrap(PushMessageParser.parse(userInfo: info)) + if isButton { + XCTAssertEqual(message.buttons, [PushButton(title: "Open order", value: "OPEN_ORDER")]) + } else { + XCTAssertEqual(message.clickAction?.stringUrl, "OPEN_ORDER") + } + } + } + + func testPushHandlerResolvesDefaultButtonAndUnknownActions() throws { + let message = try XCTUnwrap(PushMessageParser.parse(userInfo: [ + "r_group_push": "1", "push_id": "mobile_123", "click_action": "sample-shop://orders/75/", + "buttons": #"[{"title":"Open order","value":"https://example.com/order"}]"# + ])) + let handler = PushHandler(siteToken: "site-token", retryQueue: isolatedRetryQueue()) + let cases: [(String, String?)] = [ + (UNNotificationDefaultActionIdentifier, "sample-shop://orders/75/"), + ("mobile_123_0", "https://example.com/order"), ("OPEN_ORDER", nil) + ] + for (action, expected) in cases { + XCTAssertEqual(handler.actionURLString(in: message, for: action), expected, action) + } + } + + func testPushMessageParserTreatsBlankRGCategoryAsAbsent() throws { + let message = try XCTUnwrap(PushMessageParser.parse(userInfo: [ + "r_group_push": "1", + "r_group_category": " ", + "push_id": "mobile_123" + ])) + + XCTAssertNil(message.rgCategory) + } + + func testPushMessageParserIgnoresNonSDKPushPayload() { + let message = PushMessageParser.parse(userInfo: [ + "push_id": "mobile_123", + "title": "Foreign push" + ]) + + XCTAssertNil(message) + } + + func testPushMessageParserKeepsEmptyNotificationTitleAndBody() throws { + let message = try XCTUnwrap( + PushMessageParser.parse( + userInfo: [ + "r_group_push": "1", + "push_id": "mobile_123", + "title": "Fallback title", + "body": "Fallback body" + ], + title: "", + body: "" + ) + ) + + XCTAssertEqual(message.title, "") + XCTAssertEqual(message.body, "") + } + + func testPushTokensRejectEmptyValues() { + let cases: [(PushToken, PushServiceError)] = [(.fcm(" "), .emptyFCMToken), (.fid(" "), .emptyFID)] + for (token, expected) in cases { + XCTAssertThrowsError(try token.normalizedToken(), "\(token)") { error in + XCTAssertEqual(error as? PushServiceError, expected) + } + } + } + + func testRegistrarUsesCustomBackend() async throws { + CapturingURLProtocol.reset() + let registrar = PushTokenRegistrator( + session: makeCapturingSession(), + clientAPIEndpointURL: URL(string: "https://consultant.example.com/api/client/v1")! + ) + let result = await registerTokenResult( + registrar: registrar, .fcm("fcm-token"), + customerIdentity: .init(customerId: 10, siteCustomerId: nil, siteCode: nil), + siteToken: "site-token", firebaseProjectId: makeFirebaseProjectId(), categories: ["system"] + ) + assertSuccess(result) + XCTAssertEqual(CapturingURLProtocol.capturedRequest?.url?.absoluteString, + "https://consultant.example.com/api/client/v1/push-tokens") + } + + func testDefaultRegistrarSendsPushTokenRequest() async throws { + CapturingURLProtocol.reset() + let registrar = PushTokenRegistrator(session: makeCapturingSession()) + + let result = await registerTokenResult( + registrar: registrar, + .fcm("fcm-token"), + customerIdentity: .init(customerId: 10, siteCustomerId: nil, siteCode: nil), + siteToken: "site-token", + firebaseProjectId: makeFirebaseProjectId(), + categories: ["orders", "vip"] + ) + + assertSuccess(result) + + let request = try XCTUnwrap(CapturingURLProtocol.capturedRequest) + XCTAssertEqual( + request.url, + URL(string: "https://c.retailcrm.tech/api/client/v1/push-tokens") + ) + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Site-Token"), "site-token") + + let body = try decodeBody(from: request) + XCTAssertEqual(body.customerId, 10) + XCTAssertNil(body.customerExternalId) + XCTAssertNil(body.crmSiteCode) + XCTAssertEqual(body.token, "fcm-token") + XCTAssertNil(body.fid) + XCTAssertEqual(body.type, "firebase-project-id") + XCTAssertEqual(body.deviceType, "ios") + XCTAssertEqual(body.categories, ["orders", "vip"]) + } + + func testDefaultRegistrarMapsSiteIdentityToServerFields() async throws { + CapturingURLProtocol.reset() + let registrar = PushTokenRegistrator(session: makeCapturingSession()) + let result = await registerTokenResult( + registrar: registrar, + .fcm("fcm-token"), + customerIdentity: .init(siteCustomerId: "customer-123", siteCode: "main"), + siteToken: "site-token", + firebaseProjectId: makeFirebaseProjectId(), + categories: ["system"] + ) + + assertSuccess(result) + + let body = try decodeCapturedBody() + XCTAssertNil(body.customerId) + XCTAssertEqual(body.customerExternalId, "customer-123") + XCTAssertEqual(body.crmSiteCode, "main") + } + + func testDeliveryReportedByExtensionIsNotReportedAgainInApp() async throws { + CapturingURLProtocol.reset() + let session = makeCapturingSession() + defer { session.invalidateAndCancel() } + let extensionHandler = PushNotificationHandler(siteToken: "site-token", session: session, retryQueue: isolatedRetryQueue()) + let appHandler = PushHandler(siteToken: "site-token", session: session, retryQueue: isolatedRetryQueue()) + let content = UNMutableNotificationContent() + content.userInfo = ["r_group_push": true, "push_id": "mobile_123"] + let request = UNNotificationRequest(identifier: "delivery", content: content, trigger: nil) + let reported = expectation(description: "Extension reports delivery") + XCTAssertTrue(extensionHandler.handle(request: request, content: content) { _ in + reported.fulfill() + }) + await fulfillment(of: [reported], timeout: 3) + XCTAssertEqual(CapturingURLProtocol.requestCount, 1) + + let archive = NSKeyedArchiver(requiringSecureCoding: true) + archive.encode(UNNotificationRequest(identifier: "foreground", content: content, trigger: nil), forKey: "request") + archive.encode(Date(), forKey: "date") + archive.finishEncoding() + let decoder = try NSKeyedUnarchiver(forReadingFrom: archive.encodedData) + defer { decoder.finishDecoding() } + let notification = try XCTUnwrap(UNNotification(coder: decoder)) + let handled = expectation(description: "App accepts existing delivery marker") + XCTAssertTrue(appHandler.handleWillPresent(notification, completion: { result in + assertSuccess(result) + // The completion follows the request if a duplicate was accidentally sent. + XCTAssertEqual(CapturingURLProtocol.requestCount, 1) + handled.fulfill() + })) + await fulfillment(of: [handled], timeout: 3) + } + + func testPushStatusReporterPreservesHTTPErrorResponses() async { + let cases = [(400, #"{"error":"push_id is invalid"}"#, "push_id is invalid"), + (403, "forbidden", "forbidden"), (400, #"{"errors":[]}"#, #"{"errors":[]}"#)] + for (status, body, message) in cases { + CapturingURLProtocol.reset(statusCode: status, body: Data(body.utf8)) + let session = makeCapturingSession() + defer { session.invalidateAndCancel() } + let reporter = PushStatusReporter(session: session, retryQueue: isolatedRetryQueue()) + let result = await reportStatusResult(reporter: reporter, .clicked, pushId: "invalid", siteToken: "site-token") + assertFailure(result, .httpStatus(status, [message])) + } + } + + func testDefaultRegistrarPreservesHTTPErrorResponses() async { + let cases = [(400, #"{"errors":["crmSiteCode is required for customerExternalId"]}"#, "crmSiteCode is required for customerExternalId"), + (403, "forbidden", "forbidden"), (400, #"{"errors":[]}"#, #"{"errors":[]}"#)] + for (status, body, message) in cases { + CapturingURLProtocol.reset(statusCode: status, body: Data(body.utf8)) + let session = makeCapturingSession() + defer { session.invalidateAndCancel() } + let result = await registerTokenResult( + registrar: PushTokenRegistrator(session: session), .fcm("fcm-token"), + customerIdentity: .init(customerId: 10, siteCustomerId: nil, siteCode: nil), + siteToken: "site-token", firebaseProjectId: makeFirebaseProjectId(), categories: ["system"] + ) + assertFailure(result, .httpStatus(status, [message])) + if case .failure(let error) = result { + XCTAssertTrue(error.localizedDescription.contains(String(status)), body) + XCTAssertTrue(error.localizedDescription.contains(message), body) + } + } + } + +} + +private func makeFirebaseProjectId() -> String { + "firebase-project-id" +} + +private func makeCapturingSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [CapturingURLProtocol.self] + return URLSession(configuration: configuration) +} + +private func assertSuccess( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line +) { + if case let .failure(error) = result { + XCTFail("Expected success, got \(error)", file: file, line: line) + } +} + +private func assertSuccess( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line +) { + if case let .failure(error) = result { + XCTFail("Expected success, got \(error)", file: file, line: line) + } +} + +private func assertFailure( + _ result: Result, + _ expectedError: PushServiceError, + file: StaticString = #filePath, + line: UInt = #line +) { + switch result { + case .success: + XCTFail("Expected failure \(expectedError), got success", file: file, line: line) + case let .failure(error): + XCTAssertEqual(error, expectedError, file: file, line: line) + } +} + +private func assertFailure( + _ result: Result, + _ expectedError: PushStatusReportingError, + file: StaticString = #filePath, + line: UInt = #line +) { + switch result { + case .success: + XCTFail("Expected failure \(expectedError), got success", file: file, line: line) + case let .failure(error): + XCTAssertEqual(error, expectedError, file: file, line: line) + } +} + +private func reportStatusResult( + reporter: PushStatusReporter, + _ status: PushNotificationStatus, + pushId: String, + siteToken: String +) async -> Result { + await withCheckedContinuation { continuation in + reporter.reportStatus( + status, + pushId: pushId, + siteToken: siteToken + ) { result in + continuation.resume(returning: result) + } + } +} + +private func registerTokenResult( + registrar: PushTokenRegistrator, + _ token: PushToken, + customerIdentity: CrmSdkCustomerIdentity, + siteToken: String, + firebaseProjectId: String, + categories: [String] +) async -> Result { + await withCheckedContinuation { continuation in + registrar.registerToken( + token, + customerIdentity: customerIdentity, + siteToken: siteToken, + firebaseProjectId: firebaseProjectId, + categories: categories + ) { result in + continuation.resume(returning: result) + } + } +} + +private func decodeCapturedBody() throws -> PushTokenRequestBody { + let request = try XCTUnwrap(CapturingURLProtocol.capturedRequest) + return try decodeBody(from: request) +} + +private func decodeBody(from request: URLRequest) throws -> PushTokenRequestBody { + guard let body = request.httpBody ?? data(from: request.httpBodyStream) else { + XCTFail("Expected captured request body") + throw PushServiceError.invalidHTTPResponse("Missing request body") + } + + return try JSONDecoder().decode(PushTokenRequestBody.self, from: body) +} + +private func data(from stream: InputStream?) -> Data? { + guard let stream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + + while stream.hasBytesAvailable { + let readCount = stream.read(&buffer, maxLength: buffer.count) + guard readCount > 0 else { + break + } + data.append(buffer, count: readCount) + } + + return data +} + +private final class CapturingURLProtocol: URLProtocol { + + private static let lock = NSLock() + private static var requests: [URLRequest] = [] + private static var statusCode = 204 + private static var body = Data() + + static var requestCount: Int { + lock.lock() + defer { lock.unlock() } + return requests.count + } + + static var capturedRequest: URLRequest? { + lock.lock() + defer { lock.unlock() } + return requests.last + } + + static func reset(statusCode: Int = 204, body: Data = Data()) { + lock.lock() + requests = [] + self.statusCode = statusCode + self.body = body + lock.unlock() + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let statusCode = Self.statusCode + let body = Self.body + Self.lock.unlock() + + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private struct PushTokenRequestBody: Decodable { + let customerId: Int64? + let customerExternalId: String? + let crmSiteCode: String? + let token: String? + let fid: String? + let type: String + let deviceType: String + let categories: [String] +} diff --git a/Tests/PushServiceTests/PushUtmTests.swift b/Tests/PushServiceTests/PushUtmTests.swift new file mode 100644 index 0000000..9508ad2 --- /dev/null +++ b/Tests/PushServiceTests/PushUtmTests.swift @@ -0,0 +1,137 @@ +import Foundation +import UserNotifications +import XCTest +@testable import Core +@testable import PushService + +@MainActor +final class PushUtmTests: XCTestCase { + override func setUp() { + super.setUp() + CrmUtmAttributionStorage.shared.clear() + } + + override func tearDown() { + CrmUtmAttributionStorage.shared.clear() + super.tearDown() + } + + func testOpenNormalizesUtmBeforeCallbackEvenWhenStatusRequestFails() throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + let service = makeService(session: session) + let response = try makeResponse(userInfo: [ + "r_group_push": true, "push_id": "app_1", + "utm_source": " push ", "utm_medium": "mobile", "utm_campaign": "sale", + "utm_term": "shoes", "utm_content": " " + ]) + let expected = CrmUtmAttribution(source: "push", medium: "mobile", campaign: "sale", term: "shoes") + let opened = expectation(description: "Open callback sees UTM") + service.onNotificationOpen = { _ in + XCTAssertEqual(CrmUtmAttributionStorage.shared.load(), expected) + opened.fulfill() + } + let finished = expectation(description: "Response completed") + let failed = expectation(description: "Status request fails") + XCTAssertTrue(service.handleDidReceive(response, withCompletionHandler: { + finished.fulfill() + }, completion: { result in + if case .success = result { XCTFail("Expected offline error") } + failed.fulfill() + })) + XCTAssertEqual(CrmUtmAttributionStorage.shared.load(), expected) + wait(for: [opened, finished, failed], timeout: 3) + } + + func testOnlyOpensReplaceUtmAndEmptyValuesKeepPreviousAttribution() throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + let service = makeService(session: session) + let finished = expectation(description: "Responses completed") + finished.expectedFulfillmentCount = 5 + let failed = expectation(description: "Status requests fail") + failed.expectedFulfillmentCount = 5 + func handle(_ userInfo: [AnyHashable: Any], action: String = UNNotificationDefaultActionIdentifier) throws { + let response = try makeResponse(userInfo: userInfo, actionIdentifier: action) + XCTAssertTrue(service.handleDidReceive(response, withCompletionHandler: { + finished.fulfill() + }, completion: { result in + if case .success = result { XCTFail("Expected offline error") } + failed.fulfill() + })) + } + var userInfo: [AnyHashable: Any] = ["r_group_push": true, "push_id": "app_1", "utm_campaign": "first"] + try handle(userInfo) + XCTAssertEqual(CrmUtmAttributionStorage.shared.load()?.campaign, "first") + userInfo["utm_campaign"] = "second" + userInfo["buttons"] = #"[{"title":"Buy","value":"app://buy"}]"# + try handle(userInfo, action: UNNotificationDismissActionIdentifier) + XCTAssertEqual(CrmUtmAttributionStorage.shared.load()?.campaign, "first") + try handle(userInfo, action: "app_1_0") + XCTAssertEqual(CrmUtmAttributionStorage.shared.load(), CrmUtmAttribution(campaign: "second")) + userInfo.removeValue(forKey: "utm_campaign") + try handle(userInfo) + XCTAssertEqual(CrmUtmAttributionStorage.shared.load()?.campaign, "second") + userInfo["utm_source"] = " " + try handle(userInfo) + XCTAssertEqual(CrmUtmAttributionStorage.shared.load()?.campaign, "second") + wait(for: [finished, failed], timeout: 3) + } + + func testUnrelatedNotificationDoesNotSaveUtm() throws { + let session = makeSession() + defer { session.invalidateAndCancel() } + let service = makeService(session: session) + let response = try makeResponse(userInfo: ["push_id": "app_1", "utm_campaign": "other"]) + XCTAssertFalse(service.handleDidReceive(response, withCompletionHandler: { + XCTFail("Unrelated notification must not be handled") + })) + XCTAssertNil(CrmUtmAttributionStorage.shared.load()) + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [UtmFailureProtocol.self] + return URLSession(configuration: configuration) + } + + private func makeService(session: URLSession) -> PushService { + PushService( + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "test")), + pushHandler: PushHandler(siteToken: "test", session: session, retryQueue: isolatedRetryQueue()) + ) + } + + private func makeResponse( + userInfo: [AnyHashable: Any], + actionIdentifier: String = UNNotificationDefaultActionIdentifier + ) throws -> UNNotificationResponse { + let content = UNMutableNotificationContent() + content.userInfo = userInfo + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + let notificationArchive = NSKeyedArchiver(requiringSecureCoding: true) + notificationArchive.encode(request, forKey: "request") + notificationArchive.encode(Date(), forKey: "date") + notificationArchive.finishEncoding() + let notificationDecoder = try NSKeyedUnarchiver(forReadingFrom: notificationArchive.encodedData) + defer { notificationDecoder.finishDecoding() } + let notification = try XCTUnwrap(UNNotification(coder: notificationDecoder)) + + let responseArchive = NSKeyedArchiver(requiringSecureCoding: true) + responseArchive.encode(notification, forKey: "notification") + responseArchive.encode(actionIdentifier, forKey: "actionIdentifier") + responseArchive.finishEncoding() + let responseDecoder = try NSKeyedUnarchiver(forReadingFrom: responseArchive.encodedData) + defer { responseDecoder.finishDecoding() } + return try XCTUnwrap(UNNotificationResponse(coder: responseDecoder)) + } +} + +private final class UtmFailureProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } + override func stopLoading() {} +} diff --git a/Tests/RecommendationsTests/RecommendationsTests.swift b/Tests/RecommendationsTests/RecommendationsTests.swift new file mode 100644 index 0000000..56a864e --- /dev/null +++ b/Tests/RecommendationsTests/RecommendationsTests.swift @@ -0,0 +1,641 @@ +import Foundation +import XCTest +@testable import Core +@testable import Recommendations +@testable import Tracker + +final class RecommendationsTests: XCTestCase { + func testProductsUseCustomBackend() async throws { + let service = makeService(baseURL: "https://consultant.example.com/api/client/v1/").service + _ = try await service.getProducts(.init(mechanicCode: "mobile-home", blockCode: "home")) + XCTAssertEqual(RecommendationsURLProtocol.requests().compactMap { $0.url?.absoluteString }, [ + "https://consultant.example.com/api/client/v1/connect", + "https://consultant.example.com/api/client/v1/recommendations/products" + ]) + } + + override func setUp() { + super.setUp() + RecommendationsURLProtocol.reset() + } + + private func requestBody(_ request: URLRequest) throws -> Data { + if let body = request.httpBody { return body } + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { throw stream.streamError ?? URLError(.cannotDecodeRawData) } + if count == 0 { return data } + data.append(contentsOf: buffer.prefix(count)) + } + } + + func testRequestNormalizesProductContext() throws { + let request = ProductRecommendationsRequest( + mechanicCode: "mobile", blockCode: " home ", productExternalID: " product-101 ", offerExternalID: " " + ) + try request.validate() + let body = try encodedBody(request) + XCTAssertNil(body["mechanic_id"]) + XCTAssertEqual(body["mechanic_code"] as? String, "mobile") + XCTAssertEqual(body["block_code"] as? String, "home") + XCTAssertNil(body["system_customer_id"]) + XCTAssertNil(body["system_customer_external_id"]) + XCTAssertEqual(body["product_external_id"] as? String, "product-101") + XCTAssertNil(body["offer_external_id"]) + } + + func testProductsFollowCustomerIdentityChangesAndLogout() async throws { + let fixture = makeService(customerID: 37) + let request = ProductRecommendationsRequest(mechanicCode: "personal", blockCode: "home") + let identities: [CrmSdkCustomerIdentity?] = [ + CrmSdkCustomerIdentity(customerId: 81, siteCustomerId: "external-81", siteCode: "main"), + CrmSdkCustomerIdentity(customerId: nil, siteCustomerId: "external-42", siteCode: "main"), + nil + ] + for identity in identities { + try await fixture.runtime.setCustomerIdentity(identity) + _ = try await fixture.service.getProducts(request) + let request = try XCTUnwrap(RecommendationsURLProtocol.requests().last { $0.url?.path == "/api/client/v1/recommendations/products" }) + let body = try requestBody(request) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual((json["system_customer_id"] as? NSNumber)?.int64Value, identity?.customerId) + XCTAssertEqual( + json["system_customer_external_id"] as? String, + identity?.customerId == nil ? identity?.siteCustomerId : nil + ) + XCTAssertEqual(json["mechanic_code"] as? String, "personal") + XCTAssertEqual(json["block_code"] as? String, "home") + } + } + + func testMechanicCodeFormatIsDelegatedToServer() throws { + for code in ["mobile-home_1", "123", "Mobile", " mobile ", " ", "механика", "mobile/home", String(repeating: "a", count: 256)] { + let request = ProductRecommendationsRequest(mechanicCode: code, blockCode: "home") + try request.validate() + let body = try encodedBody(request) + XCTAssertEqual(body["mechanic_code"] as? String, code) + XCTAssertNil(body["mechanic_id"]) + } + } + + func testInvalidRequestStopsBeforeConnect() async throws { + let service = makeService().service + let invalidRequests = [ + ProductRecommendationsRequest(mechanicCode: "", blockCode: "home"), + ProductRecommendationsRequest(mechanicCode: "mobile", blockCode: " "), + ProductRecommendationsRequest(mechanicCode: "mobile", blockCode: "home", productID: 0), + ProductRecommendationsRequest(mechanicCode: "mobile", blockCode: "home", offerID: 0), + ProductRecommendationsRequest(mechanicCode: "mobile", blockCode: "home", productID: 1, offerID: 2) + ] + for request in invalidRequests { + do { + _ = try await service.getProducts(request) + XCTFail("Invalid request was accepted") + } catch let error as RecommendationsError { + guard case .invalidRequest = error else { return XCTFail("Wrong error: \(error)") } + } + } + XCTAssertTrue(RecommendationsURLProtocol.requests().isEmpty) + } + + func testProductsUseConnectedSession() async throws { + let service = makeService(customerID: 37).service + let result = try await service.getProducts( + ProductRecommendationsRequest(mechanicCode: "mobile-home", blockCode: " home ", productID: 101) + ) + let requests = RecommendationsURLProtocol.requests() + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests[0].url?.path, "/api/client/v1/connect") + let request = try XCTUnwrap(requests.last) + XCTAssertEqual(request.url?.path, "/api/client/v1/recommendations/products") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Site-Token"), "site-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-User-Token"), "user-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Session-Id"), "42") + let body = try requestBody(request) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["mechanic_code"] as? String, "mobile-home") + XCTAssertNil(json["mechanic_id"]) + XCTAssertEqual(json["system_customer_id"] as? Int, 37) + XCTAssertEqual(json["product_id"] as? Int, 101) + XCTAssertEqual(json["block_code"] as? String, "home") + XCTAssertNil(json["strategy"]) + XCTAssertNil(json["limit"]) + + XCTAssertEqual(result.executionID, 701) + XCTAssertEqual(result.productIds, [101]) + XCTAssertEqual(result.products.first?.id, 1001) + XCTAssertEqual(result.products.first?.productID, 101) + XCTAssertEqual(result.products.first?.oldPrice, 0) + XCTAssertEqual(result.products.first?.available, true) + } + + func testEmptySelectionAndBackendErrors() async throws { + RecommendationsURLProtocol.productBody = #"{"executionId":702,"personalized":false,"context":{"mechanicType":"popular"}}"# + let service = makeService().service + let empty = try await service.getProducts(ProductRecommendationsRequest(mechanicCode: "mobile", blockCode: "home")) + XCTAssertTrue(empty.productIds.isEmpty) + XCTAssertTrue(empty.products.isEmpty) + XCTAssertTrue(empty.context.required.isEmpty) + + for (status, code, message) in [(400, "Mobile", "Invalid product recommendation mechanic code."), + (404, "mobile", "Mechanic was not found")] { + RecommendationsURLProtocol.productStatus = status + RecommendationsURLProtocol.productBody = String(decoding: try JSONSerialization.data(withJSONObject: ["errors": [message]]), as: UTF8.self) + do { + _ = try await service.getProducts(.init(mechanicCode: code, blockCode: "home")) + XCTFail("Expected HTTP \(status)") + } catch let error as RecommendationsError { + XCTAssertEqual(error, .httpStatus(status, [message]), code) + } + } + } + + func testUnreadableResponseIsTyped() async throws { + RecommendationsURLProtocol.productBody = #"{"executionId":"invalid"}"# + let service = makeService().service + do { + _ = try await service.getProducts(ProductRecommendationsRequest(mechanicCode: "mobile", blockCode: "home")) + XCTFail("Expected response error") + } catch let error as RecommendationsError { + guard case .invalidResponse = error else { return XCTFail("Wrong error: \(error)") } + } + } + + func testImpressionAndClickUseTrackerQueue() async throws { + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + fixture.tracker.sessionDidConnect( + credentials: SessionCredentials(userToken: "user", sessionID: "42"), + pageContext: ConnectPageContext(), user: ConnectUser(), + response: ConnectResponse( + user: ConnectedUser(token: "user"), session: ConnectedSession(), + behaviour: ConnectBehaviour(tracker: true) + ), + customerIdentity: nil + ) + _ = await fixture.tracker.flush() + let recommendation = ProductRecommendations( + executionID: UInt64.max, personalized: false, + context: ProductRecommendationContext(mechanicType: "similar") + ) + let impressionID = try await fixture.service.trackImpression( + recommendation, screenURI: "app://products/101", title: "Product" + ) + let clickID = try await fixture.service.trackClick( + recommendation, productID: 101, position: 0, + element: "recommendations#similar", leaving: true + ) + let events = try fixture.storage.loadPendingEvents() + XCTAssertEqual(events.map(\.id), [impressionID, clickID]) + XCTAssertEqual(events.map(\.type), ["page_view", "click"]) + XCTAssertEqual(events[0].pageURL, "app://products/101") + XCTAssertEqual(events[1].pageURL, "app://products/101") + XCTAssertEqual(events[0].data?["recommendation_execution_id"], .uint64(UInt64.max)) + XCTAssertEqual(events[1].data?["recommendation_execution_id"], .uint64(UInt64.max)) + XCTAssertEqual(events[1].data?["product_id"], .int(101)) + XCTAssertEqual(events[1].data?["recommendation_position"], .int(0)) + XCTAssertEqual(events[1].data?["el"], .string("recommendations#similar")) + XCTAssertEqual(events[1].data?["leaving"], .bool(true)) + let json = String(decoding: try JSONEncoder().encode(events[1]), as: UTF8.self) + XCTAssertTrue(json.contains("18446744073709551615")) + } + + func testRecommendationsExposeSharedConsentAndEventAcceptance() async throws { + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + fixture.tracker.sessionDidConnect( + credentials: SessionCredentials(userToken: "user", sessionID: "42"), + pageContext: ConnectPageContext(), user: ConnectUser(), + response: ConnectResponse( + user: ConnectedUser(token: "user"), session: ConnectedSession(), + behaviour: ConnectBehaviour(tracker: true, consentForm: true) + ), + customerIdentity: nil + ) + _ = await fixture.tracker.flush() + XCTAssertTrue(fixture.tracker.requiresConsent) + XCTAssertEqual(fixture.service.consentState, .unknown) + let recommendation = ProductRecommendations( + executionID: 701, personalized: false, context: .init(mechanicType: "similar") + ) + do { + _ = try await fixture.service.trackImpression(recommendation, screenURI: "app://home") + XCTFail("Expected consent error") + } catch let error as RecommendationsError { + XCTAssertEqual(error, .consentRequired) + } + do { + _ = try await fixture.service.trackClick(recommendation, productID: 0) + XCTFail("Expected invalid product ID") + } catch let error as RecommendationsError { + guard case .invalidRequest = error else { return XCTFail("Wrong error: \(error)") } + } + XCTAssertTrue(try fixture.storage.loadPendingEvents().isEmpty) + guard case .success = fixture.service.setConsent(.granted) else { + return XCTFail("Consent was not accepted") + } + XCTAssertEqual(fixture.service.consentState, .granted) + await assertTrackingAcceptance(fixture.service, expectedError: nil) + XCTAssertEqual(try fixture.storage.loadPendingEvents().count, 1) + + guard case .success = fixture.service.setConsent(.denied) else { + return XCTFail("Consent denial was not accepted") + } + XCTAssertEqual(fixture.service.consentState, .denied) + XCTAssertTrue(try fixture.storage.loadPendingEvents().isEmpty) + do { + _ = try await fixture.service.trackImpression(recommendation, screenURI: "app://home") + XCTFail("Denied tracking was accepted") + } catch let error as RecommendationsError { + XCTAssertEqual(error, .consentRequired) + } + } + + func testReadinessSharesPreparationAndPreservesQueue() async throws { + RecommendationsURLProtocol.connectBody = #"{"user":{"token":"user-token"},"session":{"id":42},"behaviour":{"tracker":true,"consent_form":true}}"# + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + _ = try await fixture.runtime.start() + let requiresForm = try await awaitReadiness(fixture.service) + XCTAssertTrue(requiresForm) + XCTAssertTrue(fixture.tracker.requiresConsent) + await assertTrackingAcceptance(fixture.service, expectedError: .consentRequired) + _ = await fixture.tracker.flush() + try fixture.service.setConsent(.granted).get() + let recommendation = ProductRecommendations(executionID: 701, personalized: false, context: .init(mechanicType: "similar")) + let eventID = try await fixture.service.trackImpression(recommendation, screenURI: "app://home") + let repeated = try await awaitReadiness(fixture.service) + XCTAssertFalse(repeated) + XCTAssertEqual(try fixture.storage.loadPendingEvents().map(\.id), [eventID]) + XCTAssertEqual(RecommendationsURLProtocol.requests().count, 1) + + try fixture.service.setConsent(.denied).get() + RecommendationsURLProtocol.connectBody = #"{"user":{"token":"user-token"},"session":{"id":42},"behaviour":{"tracker":true,"consent_form":false}}"# + try await fixture.runtime.setCustomerIdentity(.init(customerId: 123, siteCustomerId: nil, siteCode: nil)) + let reconnected = try await awaitReadiness(fixture.service) + XCTAssertFalse(reconnected) + XCTAssertFalse(fixture.tracker.requiresConsent) + await assertTrackingAcceptance(fixture.service, expectedError: .trackingUnavailable) + XCTAssertEqual(RecommendationsURLProtocol.requests().count, 2) + } + + func testReadinessShowsFormOnlyForUnknownRequiredConsent() async throws { + for requiresConsent in [false, true] { + for (consent, expectedState): (RecommendationsConsentState, TrackerConsentState) in [(.unknown, .unknown), (.granted, .granted), (.denied, .denied)] { + RecommendationsURLProtocol.connectBody = """ + {"user":{"token":"user-token"},"session":{"id":42},"behaviour":{"tracker":true,"consent_form":\(requiresConsent)}} + """ + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + try fixture.service.setConsent(consent).get() + _ = try await fixture.runtime.start() + let requiresForm = try await awaitReadiness(fixture.service) + XCTAssertEqual(requiresForm, requiresConsent && consent == .unknown, "\(requiresConsent), \(consent)") + XCTAssertEqual(fixture.service.consentState, expectedState) + if consent == .granted { + await assertTrackingAcceptance(fixture.service, expectedError: nil) + } + } + } + } + + func testReadinessWaitsForConnectionWithoutStartingIt() async throws { + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + let premature = expectation(description: "no readiness before connection") + premature.isInverted = true + fixture.service.onReadyUpdate = { _, _ in premature.fulfill() } + await fulfillment(of: [premature], timeout: 0.1) + fixture.service.onReadyUpdate = nil + XCTAssertTrue(RecommendationsURLProtocol.requests().isEmpty) + XCTAssertFalse(fixture.storage.hasActiveScope) + RecommendationsURLProtocol.connectBody = #"{"user":{"token":"user-token"},"session":{"id":42},"behaviour":{"tracker":true,"consent_form":true}}"# + let ready = expectation(description: "automatically prepared") + fixture.service.onReadyUpdate = { requiresForm, error in + XCTAssertTrue(Thread.isMainThread) + XCTAssertTrue(requiresForm) + XCTAssertNil(error) + ready.fulfill() + } + _ = try await fixture.runtime.start() + await fulfillment(of: [ready], timeout: 3) + fixture.service.onReadyUpdate = nil + XCTAssertTrue(fixture.storage.hasActiveScope) + XCTAssertEqual(RecommendationsURLProtocol.requests().count, 1) + } + + func testReadinessReplaysFailedConnectionWithoutRetrying() async throws { + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + RecommendationsURLProtocol.connectStatus = 503 + RecommendationsURLProtocol.connectBody = #"{"errors":["Unavailable"]}"# + do { _ = try await fixture.runtime.start(); XCTFail("Expected connection failure") } + catch { XCTAssertEqual(error as? CoreError, .httpStatus(503, ["Unavailable"])) } + do { _ = try await awaitReadiness(fixture.service); XCTFail("Expected readiness failure") } + catch { XCTAssertEqual(error as? TrackerError, .httpStatus(503, ["Unavailable"], retryAfter: nil)) } + XCTAssertEqual(RecommendationsURLProtocol.requests().count, 1) + XCTAssertFalse(fixture.storage.hasActiveScope) + } + + func testReadinessHandlerIsSharedAndCanBeReplacedFromEitherModule() async throws { + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + RecommendationsURLProtocol.connectBody = #"{"user":{"token":"user-token"},"session":{"id":42},"behaviour":{"tracker":true,"consent_form":true}}"# + let replaced = expectation(description: "replaced handler is not called") + replaced.isInverted = true + fixture.tracker.onReadyUpdate = { _, _ in replaced.fulfill() } + let ready = expectation(description: "recommendations receives shared result") + fixture.service.onReadyUpdate = { requiresForm, error in + XCTAssertTrue(Thread.isMainThread) + XCTAssertTrue(requiresForm) + XCTAssertNil(error) + ready.fulfill() + } + XCTAssertNotNil(fixture.tracker.onReadyUpdate) + _ = try await fixture.runtime.start() + await fulfillment(of: [ready], timeout: 3) + await fulfillment(of: [replaced], timeout: 0.1) + + let changed = expectation(description: "tracker replaces recommendations handler") + fixture.tracker.onReadyUpdate = { requiresForm, error in + guard !requiresForm else { return } + XCTAssertTrue(Thread.isMainThread) + XCTAssertNil(error) + changed.fulfill() + } + try fixture.service.setConsent(.granted).get() + await fulfillment(of: [changed], timeout: 3) + fixture.service.onReadyUpdate = nil + XCTAssertNil(fixture.tracker.onReadyUpdate) + XCTAssertNil(fixture.service.onReadyUpdate) + + fixture.service.onReadyUpdate = { _, _ in XCTFail("Cleared handler was called") } + fixture.tracker.onReadyUpdate = nil + XCTAssertNil(fixture.service.onReadyUpdate) + try fixture.service.setConsent(.unknown).get() + await MainActor.run { } + } + + private func awaitReadiness(_ service: Recommendations) async throws -> Bool { + let ready = expectation(description: "recommendations readiness") + var result: Result? + service.onReadyUpdate = { requiresForm, error in + XCTAssertTrue(Thread.isMainThread) + result = error.map { .failure($0) } ?? .success(requiresForm) + service.onReadyUpdate = nil + ready.fulfill() + } + await fulfillment(of: [ready], timeout: 3) + service.onReadyUpdate = nil + return try XCTUnwrap(result).get() + } + + func testCompletionReportsTypedErrorOnMainThread() async { + let service = makeService().service + let recommendation = ProductRecommendations( + executionID: 701, personalized: false, + context: ProductRecommendationContext(mechanicType: "similar") + ) + let expectation = expectation(description: "completion") + service.trackClick(recommendation, productID: 0) { result in + XCTAssertTrue(Thread.isMainThread) + guard case .failure(.invalidRequest) = result else { + XCTFail("Expected typed validation error") + expectation.fulfill() + return + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 2) + } + + func testRecommendationEventsReachCollectorAndReportDelivery() async throws { + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + _ = try await fixture.runtime.start() + _ = try await awaitReadiness(fixture.service) + _ = await fixture.tracker.flush() + let selection = try await fixture.service.getProducts(.init(mechanicCode: "mobile", blockCode: "test")) + let impression = try await fixture.service.trackImpression(selection, screenURI: "app://recommendations") + let click = try await fixture.service.trackClick(selection, productID: 101, position: 0) + let delivered = expectation(description: "collector confirmed both events") + fixture.tracker.onEventUpdate = { update in + if case .delivered(let ids) = update, Set(ids) == Set([impression, click]) { delivered.fulfill() } + } + let flushed: Result = await withCheckedContinuation { continuation in + fixture.tracker.flushAll { continuation.resume(returning: $0) } + } + try flushed.get() + await fulfillment(of: [delivered], timeout: 2) + XCTAssertTrue(try fixture.storage.loadPendingEvents().isEmpty) + let request = try XCTUnwrap(RecommendationsURLProtocol.requests().last { $0.url?.path == "/api/client/v1/event" }) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-User-Token"), "user-token") + let body = try requestBody(request) + let events = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [[String: Any]]) + XCTAssertEqual(events.compactMap { $0["type"] as? String }, ["page_view", "click"]) + XCTAssertEqual((events[0]["data"] as? [String: Any])?["recommendation_execution_id"] as? Int, 701) + XCTAssertEqual((events[1]["data"] as? [String: Any])?["product_id"] as? Int, 101) + XCTAssertEqual((events[1]["data"] as? [String: Any])?["recommendation_position"] as? Int, 0) + } + + func testCollectorRejectionAndRetryAreNotReportedAsDelivery() async throws { + let fixture = try makeTrackingFixture() + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + _ = try await fixture.runtime.start() + _ = try await awaitReadiness(fixture.service) + _ = await fixture.tracker.flush() + let selection = ProductRecommendations(executionID: 701, personalized: false, context: .init(mechanicType: "similar")) + let impression = try await fixture.service.trackImpression(selection, screenURI: "app://recommendations") + let click = try await fixture.service.trackClick(selection, productID: 101) + RecommendationsURLProtocol.eventBody = String(decoding: try JSONSerialization.data(withJSONObject: [ + impression: ["try_again": "try again later"], click: ["product_id": "invalid product"] + ]), as: UTF8.self) + let rejected = expectation(description: "rejected") + let retry = expectation(description: "retry") + fixture.tracker.onEventUpdate = { update in + switch update { + case .delivered: XCTFail("Failed events were reported as delivered") + case .rejected(let id, let error): + XCTAssertEqual(id, click) + XCTAssertEqual(error, .eventRejected("product_id: invalid product")) + rejected.fulfill() + case .retryScheduled(let ids, _): + XCTAssertEqual(ids, [impression]) + retry.fulfill() + default: break + } + } + let flushed: Result = await withCheckedContinuation { continuation in + fixture.tracker.flushAll { continuation.resume(returning: $0) } + } + if case .success = flushed { XCTFail("Expected incomplete delivery") } + await fulfillment(of: [rejected, retry], timeout: 2) + XCTAssertEqual(try fixture.storage.loadPendingEvents().map(\.id), [impression]) + } + + func testRecommendationEventsAreAlsoSentByPeriodicFlush() async throws { + let fixture = try makeTrackingFixture(flushInterval: 0.05) + defer { try? FileManager.default.removeItem(at: fixture.databaseURL) } + _ = try await fixture.runtime.start() + _ = try await awaitReadiness(fixture.service) + _ = await fixture.tracker.flush() + let delivered = expectation(description: "automatic delivery") + fixture.tracker.onEventUpdate = { update in + if case .delivered = update { delivered.fulfill() } + } + let selection = ProductRecommendations(executionID: 701, personalized: false, context: .init(mechanicType: "similar")) + _ = try await fixture.service.trackImpression(selection, screenURI: "app://recommendations") + await fulfillment(of: [delivered], timeout: 3) + try fixture.service.setConsent(.denied).get() + XCTAssertTrue(RecommendationsURLProtocol.requests().contains { $0.url?.path == "/api/client/v1/event" }) + } + + private func assertTrackingAcceptance( + _ service: Recommendations, expectedError: RecommendationsError?, + file: StaticString = #filePath, line: UInt = #line + ) async { + let recommendation = ProductRecommendations( + executionID: 701, personalized: false, context: .init(mechanicType: "similar") + ) + do { + let id = try await service.trackImpression(recommendation, screenURI: "app://home") + XCTAssertNil(expectedError, "Tracking should have been rejected", file: file, line: line) + XCTAssertFalse(id.isEmpty, file: file, line: line) + } catch let error as RecommendationsError { + XCTAssertEqual(error, expectedError, file: file, line: line) + } catch { + XCTFail("Unexpected error: \(error)", file: file, line: line) + } + } + + private func encodedBody(_ request: ProductRecommendationsRequest) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any]) + } + + private func makeService( + customerID: Int64? = nil, baseURL: String = CrmSdkConfig.defaultBaseURL + ) -> (service: Recommendations, runtime: SessionRuntime) { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [RecommendationsURLProtocol.self] + let session = URLSession(configuration: configuration) + let identity = customerID.map { + CrmSdkCustomerIdentity(customerId: $0, siteCustomerId: "external-37", siteCode: "main") + } + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "site-token", baseURL: baseURL), + customerIdentity: identity, + session: session, + userTokenStorage: RecommendationTokenStorage() + ) + let tracker = Tracker(config: TrackerConfig(flushInterval: 0), sessionRuntime: runtime, client: TrackerClient(sessionRuntime: runtime, session: session)) + return (Recommendations(sessionRuntime: runtime, tracker: tracker, session: session), runtime) + } + + private func makeTrackingFixture(flushInterval: TimeInterval = 0) throws -> ( + service: Recommendations, tracker: Tracker, + storage: SQLiteTrackerStorage, databaseURL: URL, runtime: SessionRuntime + ) { + let databaseURL = FileManager.default.temporaryDirectory + .appendingPathComponent("recommendations-\(UUID().uuidString).sqlite") + let storage = try SQLiteTrackerStorage(databaseURL: databaseURL) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [RecommendationsURLProtocol.self] + let session = URLSession(configuration: configuration) + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "site-token"), + session: session, userTokenStorage: RecommendationTokenStorage() + ) + let tracker = Tracker( + config: TrackerConfig(batchSize: 50, flushInterval: flushInterval), + sessionRuntime: runtime, storage: storage, client: TrackerClient(sessionRuntime: runtime, session: session) + ) + return (Recommendations(sessionRuntime: runtime, tracker: tracker, session: session), tracker, storage, databaseURL, runtime) + } +} + +private final class RecommendationTokenStorage: UserTokenStorage { + private var token: String? + + func getUserToken(siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws -> String? { + token + } + + func setUserToken( + _ token: String?, siteToken: String, customerIdentity: CrmSdkCustomerIdentity? + ) throws { + self.token = token + } +} + +private final class RecommendationsURLProtocol: URLProtocol { + private static let lock = NSLock() + private static var capturedRequests: [URLRequest] = [] + static var connectBody = #"{"user":{"token":"user-token"},"session":{"id":42},"behaviour":{"tracker":true}}"# + static var connectStatus = 200 + static var eventBody = "{}" + static var productStatus = 200 + static var productBody = #"{"executionId":701,"personalized":false,"context":{"mechanicType":"similar","required":["product"]},"productIds":[101],"products":[{"id":1001,"productId":101,"name":"Offer","price":1500,"currency":"RUB"}]}"# + + static func reset() { + lock.lock() + capturedRequests = [] + connectBody = #"{"user":{"token":"user-token"},"session":{"id":42},"behaviour":{"tracker":true}}"# + connectStatus = 200 + eventBody = "{}" + productStatus = 200 + productBody = #"{"executionId":701,"personalized":false,"context":{"mechanicType":"similar","required":["product"]},"productIds":[101],"products":[{"id":1001,"productId":101,"name":"Offer","price":1500,"currency":"RUB"}]}"# + lock.unlock() + } + + static func requests() -> [URLRequest] { + lock.lock() + defer { lock.unlock() } + return capturedRequests + } + + override class func canInit(with request: URLRequest) -> Bool { + ["c.retailcrm.tech", "consultant.example.com"].contains(request.url?.host ?? "") + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.capturedRequests.append(request) + let connectBody = Self.connectBody + let connectStatus = Self.connectStatus + let status = Self.productStatus + let body = Self.productBody + let eventBody = Self.eventBody + Self.lock.unlock() + let isConnect = request.url?.path == "/api/client/v1/connect" + let response = HTTPURLResponse( + url: request.url!, statusCode: isConnect ? connectStatus : status, + httpVersion: nil, headerFields: ["Content-Type": "application/json"] + )! + let responseBody: String + switch request.url?.path { + case "/api/client/v1/connect": responseBody = connectBody + case "/api/client/v1/event": responseBody = eventBody + case "/api/client/v1/ts": responseBody = "{}" + default: responseBody = body + } + var responseData = Data(responseBody.utf8) + if isConnect, connectStatus == 200 { + var json = try! JSONSerialization.jsonObject(with: responseData) as! [String: Any] + json["ts"] = ISO8601DateFormatter().string(from: Date()) + responseData = try! JSONSerialization.data(withJSONObject: json) + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: responseData) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/Tests/SQLiteStorageTests/SQLiteDatabaseTests.swift b/Tests/SQLiteStorageTests/SQLiteDatabaseTests.swift new file mode 100644 index 0000000..507f73b --- /dev/null +++ b/Tests/SQLiteStorageTests/SQLiteDatabaseTests.swift @@ -0,0 +1,74 @@ +import Foundation +import XCTest +@testable import SQLiteStorage + +final class SQLiteDatabaseTests: XCTestCase { + func testValuesSurviveReopeningAndDatabaseIsExcludedFromBackup() throws { + let url = temporaryURL() + let values: [SQLiteValue] = [.null, .text("Текст ' ?\u{0} 🌍"), .integer(Int64.max), .real(12.5), .blob(Data([0, 255])), .blob(Data())] + do { + let database = try SQLiteDatabase(url: url) + try database.migrate(["CREATE TABLE sample (a, b, c, d, e, f);"]) + try database.withConnection { try $0.execute("INSERT INTO sample VALUES (?, ?, ?, ?, ?, ?);", bindings: values) } + XCTAssertEqual(try url.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup, true) + } + let reopened = try SQLiteDatabase(url: url) + XCTAssertEqual(try reopened.withConnection { try $0.query("SELECT * FROM sample;") }, [values]) + } + + func testTransactionFailureRollsBackAllWrites() throws { + let url = temporaryURL() + let database = try SQLiteDatabase(url: url) + try database.migrate(["CREATE TABLE sample (id INTEGER PRIMARY KEY);"]) + XCTAssertThrowsError(try database.transaction { connection in + try connection.execute("INSERT INTO sample VALUES (1);") + try connection.execute("INSERT INTO sample VALUES (1);") + }) + XCTAssertTrue(try database.withConnection { try $0.query("SELECT * FROM sample;").isEmpty }) + try database.transaction { try $0.execute("INSERT INTO sample VALUES (2);") } + XCTAssertEqual(try database.withConnection { try $0.query("SELECT id FROM sample;") }, [[.integer(2)]]) + } + + func testFailedMigrationRollsBackSchemaAndVersionAndCanBeRetried() throws { + let url = temporaryURL() + let database = try SQLiteDatabase(url: url) + XCTAssertThrowsError(try database.migrate(["CREATE TABLE sample (id); INSERT INTO missing VALUES (1);"])) + XCTAssertEqual(try database.withConnection { try $0.query("PRAGMA user_version;") }, [[.integer(0)]]) + try database.migrate(["CREATE TABLE sample (id);"]) + try database.migrate(["CREATE TABLE sample (id);", "ALTER TABLE sample ADD COLUMN name TEXT;"]) + XCTAssertEqual(try database.withConnection { try $0.query("PRAGMA user_version;") }, [[.integer(2)]]) + XCTAssertThrowsError(try database.migrate(["CREATE TABLE sample (id);"])) + } + + func testConcurrentTransactionsDoNotLoseUpdates() throws { + let url = temporaryURL() + let database = try SQLiteDatabase(url: url) + try database.migrate(["CREATE TABLE counter (value INTEGER); INSERT INTO counter VALUES (0);"]) + DispatchQueue.concurrentPerform(iterations: 50) { _ in + do { + try database.transaction { connection in + let value = try connection.query("SELECT value FROM counter;")[0][0].integer! + try connection.execute("UPDATE counter SET value = ?;", bindings: [.integer(value + 1)]) + } + } catch { XCTFail(error.localizedDescription) } + } + XCTAssertEqual(try database.withConnection { try $0.query("SELECT value FROM counter;") }, [[.integer(50)]]) + } + + func testDifferentFilesKeepIndependentSchemasAndData() throws { + let firstURL = temporaryURL() + let secondURL = temporaryURL() + let first = try SQLiteDatabase(url: firstURL) + let second = try SQLiteDatabase(url: secondURL) + try first.migrate(["CREATE TABLE sample (id); INSERT INTO sample VALUES (1);"]) + try second.migrate(["CREATE TABLE sample (id);"]) + XCTAssertTrue(try second.withConnection { try $0.query("SELECT * FROM sample;").isEmpty }) + XCTAssertEqual(try first.withConnection { try $0.query("SELECT * FROM sample;") }, [[.integer(1)]]) + } + + private func temporaryURL() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("SQLiteDatabaseTests-\(UUID().uuidString).sqlite") + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} diff --git a/Tests/TrackerTests/SQLiteTrackerStorageTests.swift b/Tests/TrackerTests/SQLiteTrackerStorageTests.swift new file mode 100644 index 0000000..c5aff71 --- /dev/null +++ b/Tests/TrackerTests/SQLiteTrackerStorageTests.swift @@ -0,0 +1,107 @@ +import Foundation +import SQLite3 +import XCTest +@testable import Tracker + +final class SQLiteTrackerStorageTests: XCTestCase { + func testOversizedEvictsOldestAndSurvivesReopening() throws { + let url = temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + do { + let storage = try SQLiteTrackerStorage(databaseURL: url) + try storage.activateScope(siteToken: "site", userToken: "user") + for index in 0..<55 { + let event = TrackerEvent(id: "event-\(index)", type: "click") + try storage.appendPendingEvent(event, dropping: []) + try storage.moveToOversized(event) + } + XCTAssertTrue(try storage.loadPendingEvents().isEmpty) + } + let reopened = try SQLiteTrackerStorage(databaseURL: url) + try reopened.activateScope(siteToken: "site", userToken: "user") + XCTAssertEqual(try reopened.loadOversizedEventIDs(), (5..<55).map { "event-\($0)" }) + } + + func testOwnerChangeAfterRestartDiscardsBothQueues() throws { + for newOwner in [("site", "other-user"), ("other-site", "user")] { + let url = temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + do { + let storage = try SQLiteTrackerStorage(databaseURL: url) + let first = TrackerEvent(id: "pending", type: "click") + let second = TrackerEvent(id: "second", type: "click") + XCTAssertThrowsError(try storage.appendPendingEvent(first, dropping: [])) + try storage.activateScope(siteToken: "site", userToken: "user") + try storage.appendPendingEvent(first, dropping: []) + try storage.appendPendingEvent(second, dropping: []) + XCTAssertEqual(try storage.loadPendingEvents().map(\.id), ["pending", "second"]) + try storage.moveToOversized(TrackerEvent(id: "large", type: "click")) + } + let reopened = try SQLiteTrackerStorage(databaseURL: url) + try reopened.activateScope(siteToken: newOwner.0, userToken: newOwner.1) + XCTAssertTrue(try reopened.loadPendingEvents().isEmpty) + XCTAssertTrue(try reopened.loadOversizedEventIDs().isEmpty) + try reopened.activateScope(siteToken: "site", userToken: "user") + XCTAssertTrue(try reopened.loadPendingEvents().isEmpty) + XCTAssertTrue(try reopened.loadOversizedEventIDs().isEmpty) + } + } + + func testLegacyDatabasePrunesOtherOwnersAndOversizedOverflow() throws { + let url = temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + do { + let storage = try SQLiteTrackerStorage(databaseURL: url) + try storage.activateScope(siteToken: "site", userToken: "user") + try storage.appendPendingEvent(TrackerEvent(id: "keep", type: "click"), dropping: []) + } + var database: OpaquePointer? + XCTAssertEqual(sqlite3_open(url.path, &database), SQLITE_OK) + defer { sqlite3_close(database) } + // Reproduce the old schema's multiple owners and unbounded quarantine. + XCTAssertEqual(sqlite3_exec(database, """ + INSERT INTO tracker_events VALUES ('old-owner', 'discard', 0, '{}'); + WITH RECURSIVE numbers(n) AS (SELECT 0 UNION ALL SELECT n + 1 FROM numbers WHERE n < 59) + INSERT INTO tracker_oversized_events (scope_hash, event_id, payload) + SELECT (SELECT scope_hash FROM tracker_events WHERE event_id = 'keep'), 'large-' || n, '{}' + FROM numbers ORDER BY n; + INSERT INTO tracker_oversized_events VALUES ('old-owner', 'discard-large', '{}'); + """, nil, nil, nil), SQLITE_OK) + let storage = try SQLiteTrackerStorage(databaseURL: url) + try storage.activateScope(siteToken: "site", userToken: "user") + XCTAssertEqual(try storage.loadPendingEvents().map(\.id), ["keep"]) + XCTAssertEqual(try storage.loadOversizedEventIDs(), (10..<60).map { "large-\($0)" }) + try storage.activateScope(siteToken: "site", userToken: "another-user") + XCTAssertTrue(try storage.loadOversizedEventIDs().isEmpty) + } + + func testFailedOwnerChangeRollsBackBothQueues() throws { + let url = temporaryURL() + defer { try? FileManager.default.removeItem(at: url) } + let storage = try SQLiteTrackerStorage(databaseURL: url) + try storage.activateScope(siteToken: "site", userToken: "user") + try storage.appendPendingEvent(TrackerEvent(id: "pending", type: "click"), dropping: []) + try storage.moveToOversized(TrackerEvent(id: "large", type: "click")) + var database: OpaquePointer? + XCTAssertEqual(sqlite3_open(url.path, &database), SQLITE_OK) + defer { sqlite3_close(database) } + XCTAssertEqual(sqlite3_exec(database, """ + CREATE TRIGGER fail_cleanup BEFORE DELETE ON tracker_oversized_events + BEGIN SELECT RAISE(FAIL, 'test cleanup failure'); END; + """, nil, nil, nil), SQLITE_OK) + XCTAssertThrowsError(try storage.activateScope(siteToken: "site", userToken: "other")) + XCTAssertEqual(try storage.loadPendingEvents().map(\.id), ["pending"]) + XCTAssertEqual(try storage.loadOversizedEventIDs(), ["large"]) + XCTAssertThrowsError(try storage.clearForIdentityChange()) + XCTAssertEqual(try storage.loadPendingEvents().map(\.id), ["pending"]) + XCTAssertEqual(sqlite3_exec(database, "DROP TRIGGER fail_cleanup;", nil, nil, nil), SQLITE_OK) + try storage.clearForIdentityChange() + try storage.activateScope(siteToken: "site", userToken: "user") + XCTAssertTrue(try storage.loadPendingEvents().isEmpty) + XCTAssertTrue(try storage.loadOversizedEventIDs().isEmpty) + } + + private func temporaryURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("storage-\(UUID().uuidString).sqlite") + } +} diff --git a/Tests/TrackerTests/TrackerBatchBuilderTests.swift b/Tests/TrackerTests/TrackerBatchBuilderTests.swift new file mode 100644 index 0000000..a59f77f --- /dev/null +++ b/Tests/TrackerTests/TrackerBatchBuilderTests.swift @@ -0,0 +1,35 @@ +import Foundation +import XCTest +@testable import Tracker + +final class TrackerBatchBuilderTests: XCTestCase { + func testPacksByBytesInsteadOfEventCount() throws { + let events = [event("a", bytes: 600_000), event("b", bytes: 300_000), event("c", bytes: 300_000)] + let batches = try TrackerBatchBuilder.batches(events) + XCTAssertEqual(batches.map { $0.map(\.id) }, [["a", "b"], ["c"]]) + for batch in batches { + XCTAssertLessThanOrEqual(try JSONEncoder().encode(batch).count, TrackerBatchBuilder.targetSize) + } + XCTAssertEqual(batches.flatMap { $0 }, events) + } + + func testOversizedEventRemainsWholeAndDoesNotBlockOtherBatches() throws { + let events = [event("before", bytes: 10), event("large", bytes: 3_000_000), event("after", bytes: 10)] + let batches = try TrackerBatchBuilder.batches(events) + XCTAssertEqual(batches.map { $0.map(\.id) }, [["before"], ["large"], ["after"]]) + XCTAssertEqual(batches.flatMap { $0 }, events) + XCTAssertGreaterThan(try JSONEncoder().encode(batches[1]).count, TrackerBatchBuilder.requestLimit) + } + + func testUsesEncodedBytesIncludingEscapesAndArrayPunctuation() throws { + let events = (0..<3).map { TrackerEvent(id: "\($0)", type: "click", pageTitle: "Привет 🌍\n\"/", timestamp: "1") } + let exactTwo = try JSONEncoder().encode(Array(events.prefix(2))).count + XCTAssertEqual(try TrackerBatchBuilder.batches(events, targetSize: exactTwo).map(\.count), [2, 1]) + XCTAssertEqual(try TrackerBatchBuilder.batches(events, targetSize: exactTwo - 1).map(\.count), [1, 1, 1]) + XCTAssertTrue(try TrackerBatchBuilder.batches([]).isEmpty) + } + + private func event(_ id: String, bytes: Int) -> TrackerEvent { + TrackerEvent(id: id, type: "click", pageTitle: String(repeating: "a", count: bytes), timestamp: "1") + } +} diff --git a/Tests/TrackerTests/TrackerReadinessTests.swift b/Tests/TrackerTests/TrackerReadinessTests.swift new file mode 100644 index 0000000..9e8f973 --- /dev/null +++ b/Tests/TrackerTests/TrackerReadinessTests.swift @@ -0,0 +1,289 @@ +import Foundation +import SQLite3 +import XCTest +@testable import Core +@testable import Tracker + +final class TrackerReadinessTests: XCTestCase { + override func setUp() { + super.setUp() + ReadinessProtocol.configure() + } + + func testPreparationWaitsForSharedConnectionWithoutConnectingItself() async throws { + let fixture = try await makeFixture() + defer { fixture.cleanUp() } + let waiting = expectation(description: "No readiness before connection") + waiting.isInverted = true + fixture.tracker.onReadyUpdate = { _, _ in waiting.fulfill() } + await fulfillment(of: [waiting], timeout: 0.05) + XCTAssertEqual(ReadinessProtocol.requestCount, 0) + XCTAssertFalse(fixture.storage.hasActiveScope) + + let ready = expectation(description: "Prepared automatically after connection") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertTrue(Thread.isMainThread) + XCTAssertTrue(needsForm) + XCTAssertNil(error) + XCTAssertTrue(fixture.storage.hasActiveScope) + ready.fulfill() + } + _ = try await fixture.runtime.start() + await fulfillment(of: [ready], timeout: 2) + XCTAssertEqual(ReadinessProtocol.requestCount, 1) + } + + func testTrackerCreatedAfterConnectionPreparesAndReplaysForLateSubscriber() async throws { + let fixture = try await makeFixture(createAfterConnection: true) + defer { fixture.cleanUp() } + let first = expectation(description: "First subscriber") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertTrue(needsForm) + XCTAssertNil(error) + first.fulfill() + } + await fulfillment(of: [first], timeout: 2) + fixture.tracker.onReadyUpdate = nil + let late = expectation(description: "Late subscriber gets cached result") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertTrue(Thread.isMainThread) + XCTAssertTrue(needsForm) + XCTAssertNil(error) + late.fulfill() + } + await fulfillment(of: [late], timeout: 2) + XCTAssertEqual(ReadinessProtocol.requestCount, 1) + } + + func testConsentChangeUpdatesCachedReadinessFlag() async throws { + let fixture = try await makeFixture() + defer { fixture.cleanUp() } + let unknown = expectation(description: "Consent form required") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertTrue(needsForm) + XCTAssertNil(error) + unknown.fulfill() + } + _ = try await fixture.runtime.start() + await fulfillment(of: [unknown], timeout: 2) + fixture.tracker.onReadyUpdate = nil + try fixture.tracker.setConsent(.granted).get() + let restored = expectation(description: "Late subscriber sees granted consent") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertFalse(needsForm) + XCTAssertNil(error) + restored.fulfill() + } + await fulfillment(of: [restored], timeout: 2) + fixture.tracker.onReadyUpdate = nil + let requiredAgain = expectation(description: "Readiness updates after consent changes") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertNil(error) + if needsForm { requiredAgain.fulfill() } + } + try fixture.tracker.setConsent(.unknown).get() + await fulfillment(of: [requiredAgain], timeout: 2) + } + + func testSavedConsentIsAppliedBeforeAutomaticPreparation() async throws { + let fixture = try await makeFixture() + defer { fixture.cleanUp() } + try fixture.tracker.setConsent(.denied).get() + let ready = expectation(description: "Saved decision suppresses consent form") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertFalse(needsForm) + XCTAssertNil(error) + ready.fulfill() + } + _ = try await fixture.runtime.start() + await fulfillment(of: [ready], timeout: 2) + XCTAssertTrue(fixture.tracker.requiresConsent) + } + + func testConnectionFailureIsReportedAndLaterConnectionRecovers() async throws { + let fixture = try await makeFixture() + defer { fixture.cleanUp() } + ReadinessProtocol.configure(status: 503) + let failed = expectation(description: "Shared connection failure") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertTrue(Thread.isMainThread) + XCTAssertFalse(needsForm) + XCTAssertEqual(error, .httpStatus(503, ["Unavailable"], retryAfter: nil)) + failed.fulfill() + } + do { + _ = try await fixture.runtime.start() + XCTFail("Expected connection failure") + } catch {} + await fulfillment(of: [failed], timeout: 2) + XCTAssertFalse(fixture.storage.hasActiveScope) + let recovered = expectation(description: "Later successful connection") + fixture.tracker.onReadyUpdate = { needsForm, error in + if error == nil { + XCTAssertTrue(needsForm) + recovered.fulfill() + } + } + ReadinessProtocol.configure() + _ = try await fixture.runtime.start() + await fulfillment(of: [recovered], timeout: 2) + } + + func testTrackerCreatedAfterConnectionFailureGetsCurrentErrorWithoutRetrying() async throws { + ReadinessProtocol.configure(status: 503) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ReadinessProtocol.self] + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "site"), session: session, userTokenStorage: ReadinessTokenStorage() + ) + _ = try? await runtime.start() + let tracker = Tracker(config: TrackerConfig(flushInterval: 0), sessionRuntime: runtime) + let failed = expectation(description: "Previously failed connection replayed") + tracker.onReadyUpdate = { needsForm, error in + XCTAssertFalse(needsForm) + XCTAssertEqual(error, .httpStatus(503, ["Unavailable"], retryAfter: nil)) + failed.fulfill() + } + await fulfillment(of: [failed], timeout: 2) + XCTAssertEqual(ReadinessProtocol.requestCount, 1) + } + + func testStoragePreparationFailureReachesReadyHandler() async throws { + let fixture = try await makeFixture() + defer { fixture.cleanUp() } + var database: OpaquePointer? + XCTAssertEqual(sqlite3_open(fixture.url.path, &database), SQLITE_OK) + defer { sqlite3_close(database) } + XCTAssertEqual(sqlite3_exec(database, "DROP TABLE tracker_events;", nil, nil, nil), SQLITE_OK) + let failed = expectation(description: "Local queue preparation failed") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertTrue(Thread.isMainThread) + XCTAssertFalse(needsForm) + guard case .storage? = error else { return XCTFail("Expected storage failure: \(String(describing: error))") } + failed.fulfill() + } + _ = try await fixture.runtime.start() + await fulfillment(of: [failed], timeout: 2) + } + + func testReconnectDoesNotReplayOldReadinessWhileNewSessionIsConnecting() async throws { + let fixture = try await makeFixture() + defer { fixture.cleanUp() } + let ready = expectation(description: "First session ready") + fixture.tracker.onReadyUpdate = { _, error in + XCTAssertNil(error) + ready.fulfill() + } + _ = try await fixture.runtime.start() + await fulfillment(of: [ready], timeout: 2) + fixture.tracker.onReadyUpdate = nil + ReadinessProtocol.configure(hold: true) + let reconnect = Task { + try await fixture.runtime.setCustomerIdentity(CrmSdkCustomerIdentity(customerId: 42)) + } + await waitUntil { ReadinessProtocol.hasHeldResponse } + let waiting = expectation(description: "Old session result is not replayed") + waiting.isInverted = true + fixture.tracker.onReadyUpdate = { _, _ in waiting.fulfill() } + await fulfillment(of: [waiting], timeout: 0.05) + let reconnected = expectation(description: "New session prepared") + fixture.tracker.onReadyUpdate = { needsForm, error in + XCTAssertTrue(needsForm) + XCTAssertNil(error) + reconnected.fulfill() + } + ReadinessProtocol.releaseResponse() + try await reconnect.value + await fulfillment(of: [reconnected], timeout: 2) + } + + func testAutomaticLifecycleRegistrationDoesNotRetainTracker() async { + let runtime = SessionRuntime(config: SessionConfig(siteToken: "site")) + var tracker: Tracker? = Tracker(config: TrackerConfig(flushInterval: 0), sessionRuntime: runtime) + weak var released = tracker + if let tracker { await runtime.addLifecycleListener(tracker) } + tracker = nil + await waitUntil { released == nil } + } + + private func makeFixture(createAfterConnection: Bool = false) async throws -> Fixture { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ReadinessProtocol.self] + let session = URLSession(configuration: configuration) + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "site"), session: session, userTokenStorage: ReadinessTokenStorage() + ) + if createAfterConnection { _ = try await runtime.start() } + let url = FileManager.default.temporaryDirectory.appendingPathComponent("ready-\(UUID().uuidString).sqlite") + let storage = try SQLiteTrackerStorage(databaseURL: url) + let tracker = Tracker(config: TrackerConfig(flushInterval: 0), sessionRuntime: runtime, storage: storage) + return Fixture(tracker: tracker, runtime: runtime, storage: storage, session: session, url: url) + } + + private func waitUntil(file: StaticString = #filePath, line: UInt = #line, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(2) + while !condition(), Date() < deadline { try? await Task.sleep(nanoseconds: 1_000_000) } + XCTAssertTrue(condition(), file: file, line: line) + } + + private struct Fixture { + let tracker: Tracker + let runtime: SessionRuntime + let storage: SQLiteTrackerStorage + let session: URLSession + let url: URL + func cleanUp() { + session.invalidateAndCancel() + try? FileManager.default.removeItem(at: url) + } + } +} + +private final class ReadinessTokenStorage: UserTokenStorage { + func getUserToken(siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws -> String? { nil } + func setUserToken(_ token: String?, siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws {} +} + +private final class ReadinessProtocol: URLProtocol { + private static let lock = NSLock() + private static var status = 200 + private static var requests = 0 + private static var holdsResponse = false + private static var heldResponse: ReadinessProtocol? + static var requestCount: Int { lock.withLock { requests } } + static var hasHeldResponse: Bool { lock.withLock { heldResponse != nil } } + static func configure(status: Int = 200, hold: Bool = false) { + lock.withLock { self.status = status; requests = 0; holdsResponse = hold } + } + static func releaseResponse() { + let response = lock.withLock { () -> ReadinessProtocol? in + defer { heldResponse = nil; holdsResponse = false } + return heldResponse + } + response?.respond() + } + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let held = Self.lock.withLock { + Self.requests += 1 + if Self.holdsResponse { Self.heldResponse = self } + return Self.holdsResponse + } + if !held { respond() } + } + private func respond() { + let status = Self.lock.withLock { Self.status } + let body = status == 200 + ? #"{"ts":"2026-10-28T12:00:00Z","user":{"token":"user"},"session":{"id":1},"behaviour":{"tracker":true,"consent_form":true}}"# + : #"{"errors":["Unavailable"]}"# + client?.urlProtocol(self, didReceive: HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil + )!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/Tests/TrackerTests/TrackerTests.swift b/Tests/TrackerTests/TrackerTests.swift new file mode 100644 index 0000000..63d6841 --- /dev/null +++ b/Tests/TrackerTests/TrackerTests.swift @@ -0,0 +1,527 @@ +import Foundation +import XCTest +import SQLite3 +@testable import Core +@testable import Tracker + +final class TrackerTests: XCTestCase { + func testCollectorAndServerTimeUseCustomBackend() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [UtmTrackerProtocol.self] + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + UtmTrackerProtocol.requests = [] + UtmTrackerProtocol.requestURLs = [] + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "site", baseURL: "http://localhost:8080/proxy/"), + session: session, userTokenStorage: UtmTokenStorage() + ) + _ = try await runtime.start() + let client = TrackerClient(sessionRuntime: runtime, session: session) + _ = try await client.fetchServerTime() + // The fake collector fails the first event request, then accepts the retry. + _ = try? await client.send(events: [TrackerEvent(type: "click")], expectedUserToken: "user") + _ = try await client.send(events: [TrackerEvent(type: "click")], expectedUserToken: "user") + XCTAssertEqual(UtmTrackerProtocol.requestURLs.map(\.absoluteString), [ + "http://localhost:8080/proxy/api/client/v1/connect", + "http://localhost:8080/proxy/api/client/v1/ts?user=user&session=1", + "http://localhost:8080/proxy/api/client/v1/event?user=user&session=1", + "http://localhost:8080/proxy/api/client/v1/event?user=user&session=1" + ]) + } + + func testSendingPayloadMatchesRequestBodyForFailedAndSuccessfulAttempts() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [UtmTrackerProtocol.self] + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + UtmTrackerProtocol.requests = [] + let runtime = SessionRuntime( + config: SessionConfig(siteToken: "payload-test"), session: session, userTokenStorage: UtmTokenStorage() + ) + let _: ConnectResponse = try await runtime.connect(responseType: ConnectResponse.self) + let client = TrackerClient(sessionRuntime: runtime, session: session) + let events = [ + TrackerEvent(id: "first", type: "click", pageTitle: "Товар 🌍\n\"/", systemCustomerID: 42), + TrackerEvent(id: "second", type: "order", utm: CrmUtmAttribution(campaign: "push")) + ] + var payloads: [String] = [] + for attempt in 0..<2 { + do { + _ = try await client.send(events: events, expectedUserToken: "user") { payload in + // The callback must precede the corresponding network attempt. + XCTAssertEqual(UtmTrackerProtocol.requests.count, attempt) + payloads.append(payload) + } + XCTAssertEqual(attempt, 1) + } catch { + XCTAssertEqual(attempt, 0) + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + } + XCTAssertEqual(payloads.count, 2) + XCTAssertEqual(UtmTrackerProtocol.requests.count, 2) + XCTAssertEqual(payloads.map { Data($0.utf8) }, UtmTrackerProtocol.requests) + for payload in payloads { + XCTAssertEqual(try JSONDecoder().decode([TrackerEvent].self, from: Data(payload.utf8)), events) + } + } + + func testSendingIsNotReportedWithoutConnection() async throws { + let runtime = SessionRuntime(config: SessionConfig(siteToken: "payload-test")) + let client = TrackerClient(sessionRuntime: runtime) + do { + _ = try await client.send(events: [TrackerEvent(type: "click")], expectedUserToken: "user") { _ in + XCTFail("No request can be sent without connection credentials") + } + XCTFail("Expected missingConnection") + } catch { + XCTAssertEqual(error as? TrackerError, .missingConnection) + } + } + + func testRestoredOrderIsSentWithOriginalUtmAfterNetworkFailure() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [UtmTrackerProtocol.self] + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + UtmTrackerProtocol.requests = [] + let runtime = SessionRuntime(config: SessionConfig(siteToken: "utm-test"), session: session, userTokenStorage: UtmTokenStorage()) + let _: ConnectResponse = try await runtime.connect(responseType: ConnectResponse.self) + let client = TrackerClient(sessionRuntime: runtime, session: session) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("utm-retry-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + let order = TrackerEvent(type: "order", utm: CrmUtmAttribution(campaign: "original")) + do { + let storage = try SQLiteTrackerStorage(databaseURL: url) + try storage.activateScope(siteToken: "utm-test", userToken: "user") + try storage.appendPendingEvent(order, dropping: []) + do { + _ = try await client.send(events: [order], expectedUserToken: "user") + XCTFail("Expected network failure") + } catch { XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) } + } + let restoredStorage = try SQLiteTrackerStorage(databaseURL: url) + try restoredStorage.activateScope(siteToken: "utm-test", userToken: "user") + let events = try restoredStorage.loadPendingEvents() + _ = try await client.send(events: events, expectedUserToken: "user") + XCTAssertEqual(UtmTrackerProtocol.requests.count, 2) + for body in UtmTrackerProtocol.requests { + XCTAssertEqual(try JSONDecoder().decode([TrackerEvent].self, from: body), [order]) + } + } + + func testOrderUtmSurvivesStartupRetryAndStorageReopening() async throws { + let fixture = try makeStartupFixture() + defer { + CrmUtmAttributionStorage.shared.clear() + try? FileManager.default.removeItem(at: fixture.url) + } + let original = try XCTUnwrap(CrmUtmAttribution(source: "push", campaign: "first", term: "shoes")) + CrmUtmAttributionStorage.shared.save(original) + let orderID = try await fixture.tracker.trackOrder(identifier: .externalID("order-utm")) + CrmUtmAttributionStorage.shared.save(try XCTUnwrap(CrmUtmAttribution(campaign: "second"))) + let clickID = try await fixture.tracker.trackClick(element: "buy") + await connectStartupFixture(fixture.tracker) + let events = try fixture.storage.loadPendingEvents() + let order = try XCTUnwrap(events.first { $0.id == orderID }) + XCTAssertEqual(order.utm, original) + XCTAssertEqual(order.systemCustomerID, 42) + XCTAssertNil(events.first { $0.id == clickID }?.utm) + // A failed flush must leave the persisted event intact. + _ = await fixture.tracker.flush() + CrmUtmAttributionStorage.shared.clear() + let reopened = try SQLiteTrackerStorage(databaseURL: fixture.url) + try reopened.activateScope(siteToken: "site", userToken: "user") + let restored = try XCTUnwrap(reopened.loadPendingEvents().first { $0.id == orderID }) + XCTAssertEqual(restored, order) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(restored)) as? [String: Any]) + let utm = try XCTUnwrap(json["utm"] as? [String: String]) + XCTAssertEqual(utm, ["utm_source": "push", "utm_campaign": "first", "utm_keyword": "shoes"]) + XCTAssertNil(utm["utm_term"]) + let noUtmID = try await fixture.tracker.trackOrder(identifier: .externalID("no-utm")) + XCTAssertNil(try reopened.loadPendingEvents().first { $0.id == noUtmID }?.utm) + } + + func testOrderEventMatchesBackendJSONContract() throws { + let event = TrackerEvent( + id: "event-1", type: "order", + data: try TrackerEventDataFactory.order( + identifier: .externalID("order-42"), cartID: "cart-7", + items: [TrackerCartItem(offer: .xmlID("offer-3"), price: 12.5, quantity: 2)], + deliveryPrice: 5, total: 30 + ), + pageURL: "app://shop/cart", timestamp: "2026-10-14T10:00:00Z", + systemCustomerID: 42 + ) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(event)) as? [String: Any]) + let data = try XCTUnwrap(json["data"] as? [String: Any]) + let items = try XCTUnwrap(data["items"] as? [[String: Any]]) + XCTAssertEqual(json["type"] as? String, "order") + XCTAssertEqual(json["page_url"] as? String, "app://shop/cart") + XCTAssertEqual(json["system_customer_id"] as? Int, 42) + XCTAssertEqual(data["order_external_id"] as? String, "order-42") + XCTAssertEqual(data["cart_id"] as? String, "cart-7") + XCTAssertEqual(items.first?["xml_id"] as? String, "offer-3") + XCTAssertEqual(items.first?["quantity"] as? Int, 2) + XCTAssertNil(json["system_customer_external_id"]) + } + + func testOrderIdentifiersSurviveQueueStorageAndJSONEncoding() async throws { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + let cases: [(TrackerOrderIdentifier, String, TrackerEventDataValue)] = [ + (.systemID(1), "order_id", .int(1)), + (.systemID(Int64(Int32.max)), "order_id", .int(Int(Int32.max))), + (.externalID("order-42"), "order_external_id", .string("order-42")), + (.number("R-42"), "order_number", .string("R-42")) + ] + var eventIDs: [String] = [] + for (identifier, _, _) in cases { + eventIDs.append(try await fixture.tracker.trackOrder(identifier: identifier)) + } + await connectStartupFixture(fixture.tracker) + let reopened = try SQLiteTrackerStorage(databaseURL: fixture.url) + try reopened.activateScope(siteToken: "site", userToken: "user") + let events = try reopened.loadPendingEvents() + for (index, entry) in cases.enumerated() { + let (_, key, value) = entry + let event = try XCTUnwrap(events.first { $0.id == eventIDs[index] }) + XCTAssertEqual(event.type, "order") + XCTAssertEqual(event.data, [key: value]) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(event)) as? [String: Any]) + let data = try XCTUnwrap(json["data"] as? [String: Any]) + if case let .int(id) = value { + XCTAssertEqual(data[key] as? Int64, Int64(id)) + XCTAssertFalse(data[key] is String) + } else if case let .string(id) = value { + XCTAssertEqual(data[key] as? String, id) + } + for otherKey in ["order_id", "order_external_id", "order_number"] where otherKey != key { + XCTAssertNil(data[otherKey]) + } + } + } + + func testOrderSystemIDRejectsValuesOutsideCRMRange() async throws { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + for id: Int64 in [Int64.min, -1, 0, Int64(Int32.max) + 1, Int64.max] { + do { + _ = try await fixture.tracker.trackOrder(identifier: .systemID(id)) + XCTFail("Expected invalidEvent for order ID \(id)") + } catch { + guard case .invalidEvent = error as? TrackerError else { + return XCTFail("Expected invalidEvent, got \(error)") + } + } + let result = await withCheckedContinuation { continuation in + fixture.tracker.trackOrder(identifier: .systemID(id)) { + continuation.resume(returning: $0) + } + } + guard case .failure(.invalidEvent) = result else { + return XCTFail("Expected invalidEvent for order ID \(id)") + } + } + await connectStartupFixture(fixture.tracker) + XCTAssertTrue(try fixture.storage.loadPendingEvents().isEmpty) + } + + func testBackendResponseSeparatesRetryAndRejection() throws { + let response = try JSONDecoder().decode( + TrackerStoreEventsResponse.self, + from: Data("{\"one\":{\"page_url\":\"try again later\"},\"two\":{\"data\":\"invalid offer\"}}".utf8) + ) + let failures = response.failures(attemptedEventIDs: ["one", "two", "three"]) + XCTAssertEqual(failures.retryableIDs, ["one"]) + XCTAssertEqual(failures.rejectedReasons, ["two": "data: invalid offer"]) + XCTAssertThrowsError(try JSONDecoder().decode( + TrackerStoreEventsResponse.self, + from: Data("{\"unexpected\":[1,2]}".utf8) + )) + } + + func testTrackerRequiresConsentAndClearsQueueOnDenial() async throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("tracker-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + let storage = try SQLiteTrackerStorage(databaseURL: url) + let runtime = SessionRuntime(config: SessionConfig(siteToken: "site")) + let tracker = Tracker( + config: TrackerConfig(batchSize: 50, flushInterval: 0), + sessionRuntime: runtime, storage: storage + ) + guard case .success = tracker.setConsent(.granted) else { + return XCTFail("Consent could not be granted.") + } + tracker.sessionDidConnect( + credentials: SessionCredentials(userToken: "user", sessionID: "session"), + pageContext: ConnectPageContext(), user: ConnectUser(), + response: ConnectResponse( + user: ConnectedUser(token: "user"), + session: ConnectedSession(), + behaviour: ConnectBehaviour(tracker: true, consentForm: true) + ), + customerIdentity: nil + ) + _ = await tracker.flush() + let queuedID = try await tracker.trackClick(element: "buy") + XCTAssertFalse(queuedID.isEmpty) + XCTAssertEqual(tracker.pendingEventCount, 1) + XCTAssertEqual(try storage.loadPendingEvents().count, 1) + guard case .success = tracker.setConsent(.denied) else { + return XCTFail("Consent could not be denied.") + } + XCTAssertEqual(tracker.pendingEventCount, 0) + XCTAssertTrue(try storage.loadPendingEvents().isEmpty) + let deniedResult = await withCheckedContinuation { continuation in + tracker.trackClick(element: "buy") { continuation.resume(returning: $0) } + } + XCTAssertEqual(deniedResult, .failure(.consentRequired)) + } + + func testInvalidPublicEventReturnsTrackerError() async { + let runtime = SessionRuntime(config: SessionConfig(siteToken: "site")) + let tracker = Tracker(config: TrackerConfig(), sessionRuntime: runtime) + let pageResult = await withCheckedContinuation { continuation in + tracker.trackPageView(screenURI: "relative/path") { continuation.resume(returning: $0) } + } + guard case let .failure(.invalidEvent(uriReason)) = pageResult else { + return XCTFail("Expected a TrackerError for a relative URI.") + } + XCTAssertTrue(uriReason.contains("screenURI")) + let cartResult = await withCheckedContinuation { continuation in + tracker.trackCart( + cartID: "cart", + items: [TrackerCartItem(offer: .systemID(1), price: -.infinity, quantity: 1)] + ) { continuation.resume(returning: $0) } + } + guard case let .failure(.invalidEvent(priceReason)) = cartResult else { + return XCTFail("Expected a TrackerError for an infinite price.") + } + XCTAssertTrue(priceReason.contains("price")) + + do { + _ = try await tracker.trackPageView(screenURI: "relative/path") + XCTFail("Expected an invalid URI error from the async method.") + } catch let error as TrackerError { + guard case .invalidEvent = error else { + return XCTFail("Unexpected error: \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testAsyncFlushAllReportsMissingConnection() async { + let runtime = SessionRuntime(config: SessionConfig(siteToken: "site")) + let tracker = Tracker(config: TrackerConfig(), sessionRuntime: runtime) + + do { + try await tracker.flushAll() + XCTFail("Expected a missing connection error.") + } catch let error as TrackerError { + XCTAssertEqual(error, .missingConnection) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testStartupBufferPreservesOrderContextAndEventIDs() async throws { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + let first = try await fixture.tracker.trackPageView(screenURI: "app://shop", title: "Shop") + let click = try await fixture.tracker.trackClick(element: "product") + let second = try await fixture.tracker.trackPageView(screenURI: "app://product", title: "Product") + let form = try await fixture.tracker.trackFormStart(formSelector: "checkout") + let latestStartupTimestamp = TrackerEventTimestamp.make() + await connectStartupFixture(fixture.tracker) + let later = try await fixture.tracker.trackClick(element: "buy") + let events = try fixture.storage.loadPendingEvents() + XCTAssertEqual(events.map(\.id), [first, click, second, form, later]) + XCTAssertEqual(events.map(\.pageURL), ["app://shop", "app://shop", "app://product", "app://product", "app://product"]) + XCTAssertEqual(events[2].referrerURL, "app://shop") + XCTAssertEqual(events[3].referrerURL, "app://shop") + XCTAssertTrue(events.prefix(4).allSatisfy { $0.timestamp <= latestStartupTimestamp }) + XCTAssertTrue(events.allSatisfy { $0.systemCustomerID == 42 }) + } + + func testStartupConsentAndAvailability() async throws { + for consent: TrackerConsentState in [.unknown, .granted, .denied] { + for requiresConsent in [false, true] { + for available in [false, true] { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + try fixture.tracker.setConsent(consent).get() + let result = await withCheckedContinuation { continuation in + fixture.tracker.trackClick(element: "early") { continuation.resume(returning: $0) } + } + if consent == .denied { + XCTAssertEqual(result, .failure(.consentRequired)) + } else { + _ = try result.get() + } + await connectStartupFixture(fixture.tracker, requiresConsent: requiresConsent, available: available) + let shouldRetain = available && consent != .denied && (!requiresConsent || consent == .granted) + XCTAssertEqual(try fixture.storage.loadPendingEvents().count, shouldRetain ? 1 : 0) + // Later consent must not resurrect discarded startup history. + try fixture.tracker.setConsent(.granted).get() + XCTAssertEqual(try fixture.storage.loadPendingEvents().count, shouldRetain ? 1 : 0) + } + } + } + } + + func testStartupDenialAndDisconnectClearBufferedEvents() async throws { + for disconnect in [false, true] { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + let id = try await fixture.tracker.trackPageView(screenURI: "app://old-user") + let dropped = expectation(description: "buffer cleared") + fixture.tracker.onEventUpdate = { update in + if case .dropped(let ids, _) = update, ids.contains(id) { dropped.fulfill() } + } + if disconnect { + fixture.tracker.sessionDidDisconnect() + } else { + try fixture.tracker.setConsent(.denied).get() + try fixture.tracker.setConsent(.granted).get() + } + await fulfillment(of: [dropped], timeout: 2) + await connectStartupFixture(fixture.tracker) + XCTAssertTrue(try fixture.storage.loadPendingEvents().isEmpty) + _ = try await fixture.tracker.trackClick(element: "new-user") + XCTAssertNil(try fixture.storage.loadPendingEvents().first?.pageURL) + } + } + + func testStartupBufferDropsOldestEventsAtCapacity() async throws { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + var ids: [String] = [] + for index in 0..<1_001 { + ids.append(try await fixture.tracker.trackClick(element: "item-\(index)")) + } + await connectStartupFixture(fixture.tracker) + XCTAssertEqual(try fixture.storage.loadPendingEvents().map(\.id), Array(ids.suffix(1_000))) + } + + func testStartupStorageFailureRetainsBufferAndUserChangeDropsIt() async throws { + for changeUser in [false, true] { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + var database: OpaquePointer? + XCTAssertEqual(sqlite3_open(fixture.url.path, &database), SQLITE_OK) + defer { sqlite3_close(database) } + XCTAssertEqual(sqlite3_exec(database, """ + CREATE TRIGGER fail_startup BEFORE INSERT ON tracker_events + BEGIN SELECT RAISE(FAIL, 'test write failure'); END; + """, nil, nil, nil), SQLITE_OK) + let id = try await fixture.tracker.trackClick(element: "startup") + await connectStartupFixture(fixture.tracker) + let flushResult = await fixture.tracker.flush() + guard case .failure(.storage) = flushResult else { + return XCTFail("Expected a storage failure, got \(flushResult)") + } + XCTAssertTrue(try fixture.storage.loadPendingEvents().isEmpty) + XCTAssertEqual(sqlite3_exec(database, "DROP TRIGGER fail_startup", nil, nil, nil), SQLITE_OK) + await connectStartupFixture(fixture.tracker, userToken: changeUser ? "other-user" : "user") + XCTAssertEqual(try fixture.storage.loadPendingEvents().map(\.id), changeUser ? [] : [id]) + } + } + + func testDisconnectClearsPersistedEventsBeforeReconnect() async throws { + let fixture = try makeStartupFixture() + defer { try? FileManager.default.removeItem(at: fixture.url) } + await connectStartupFixture(fixture.tracker) + _ = try await fixture.tracker.trackClick(element: "old-user") + try fixture.storage.moveToOversized(TrackerEvent(id: "large", type: "click")) + fixture.tracker.sessionDidDisconnect() + // No successful reconnect is required for deleting the previous customer's data. + let reopened = try SQLiteTrackerStorage(databaseURL: fixture.url) + try reopened.activateScope(siteToken: "site", userToken: "user") + XCTAssertTrue(try reopened.loadPendingEvents().isEmpty) + XCTAssertTrue(try reopened.loadOversizedEventIDs().isEmpty) + await connectStartupFixture(fixture.tracker) + XCTAssertTrue(try fixture.storage.loadPendingEvents().isEmpty) + } + + private func makeStartupFixture() throws -> (tracker: Tracker, storage: SQLiteTrackerStorage, url: URL) { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("startup-\(UUID().uuidString).sqlite") + let storage = try SQLiteTrackerStorage(databaseURL: url) + let tracker = Tracker( + config: TrackerConfig(batchSize: 50, flushInterval: 0), + sessionRuntime: SessionRuntime(config: SessionConfig(siteToken: "site")), storage: storage + ) + return (tracker, storage, url) + } + + private func connectStartupFixture( + _ tracker: Tracker, requiresConsent: Bool = false, available: Bool = true, userToken: String = "user" + ) async { + tracker.sessionDidConnect( + credentials: SessionCredentials(userToken: userToken, sessionID: "session"), + pageContext: ConnectPageContext(), user: ConnectUser(), + response: ConnectResponse( + user: ConnectedUser(token: userToken), session: ConnectedSession(), + behaviour: ConnectBehaviour(tracker: available, consentForm: requiresConsent) + ), + customerIdentity: CrmSdkCustomerIdentity(customerId: 42, siteCustomerId: nil, siteCode: nil) + ) + _ = await tracker.flush() + } + + func testStringErrorsHaveLocalizedDescriptions() { + let errors: [TrackerError] = [ + .invalidEvent("detail"), .storage("detail"), + .requestEncodingFailed("detail"), .requestFailed("detail"), + .invalidResponse("detail"), .eventRejected("detail"), + .httpStatus(400, ["detail"], retryAfter: nil) + ] + for error in errors { + let description = error.errorDescription ?? "" + XCTAssertTrue(description.contains("detail")) + XCTAssertNotEqual(description, "detail") + XCTAssertFalse(description.contains("tracker.error.")) + } + } +} + +private final class UtmTrackerProtocol: URLProtocol { + static var requests: [Data] = [] + static var requestURLs: [URL] = [] + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + Self.requestURLs.append(request.url!) + let isEvent = request.url!.lastPathComponent == "event" + if isEvent { + var body = request.httpBody ?? Data() + if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { break } + body.append(buffer, count: count) + } + } + Self.requests.append(body) + if Self.requests.count == 1 { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + return + } + } + let data = Data((isEvent ? "{}" : #"{"user":{"token":"user"},"session":{"id":1},"behaviour":{"tracker":true}}"#).utf8) + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +private final class UtmTokenStorage: UserTokenStorage { + func getUserToken(siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws -> String? { nil } + func setUserToken(_ token: String?, siteToken: String, customerIdentity: CrmSdkCustomerIdentity?) throws {} +}