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 0000000..8c0179e Binary files /dev/null and b/Sources/OnlineConsultant/Resources/ActionImages/confetti.png differ diff --git a/Sources/OnlineConsultant/Resources/ActionImages/fire.png b/Sources/OnlineConsultant/Resources/ActionImages/fire.png new file mode 100644 index 0000000..a100636 Binary files /dev/null and b/Sources/OnlineConsultant/Resources/ActionImages/fire.png differ diff --git a/Sources/OnlineConsultant/Resources/ActionImages/hello.png b/Sources/OnlineConsultant/Resources/ActionImages/hello.png new file mode 100644 index 0000000..3c39e9d Binary files /dev/null and b/Sources/OnlineConsultant/Resources/ActionImages/hello.png differ diff --git a/Sources/OnlineConsultant/Resources/ActionImages/present.png b/Sources/OnlineConsultant/Resources/ActionImages/present.png new file mode 100644 index 0000000..bc263f2 Binary files /dev/null and b/Sources/OnlineConsultant/Resources/ActionImages/present.png differ diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/Contents.json b/Sources/OnlineConsultant/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_attachment_icon.imageset/Contents.json b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_attachment_icon.imageset/Contents.json new file mode 100644 index 0000000..fa69490 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_attachment_icon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "attachment.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_attachment_icon.imageset/attachment.svg b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_attachment_icon.imageset/attachment.svg new file mode 100644 index 0000000..7c18f18 --- /dev/null +++ b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_attachment_icon.imageset/attachment.svg @@ -0,0 +1,3 @@ + + + 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 0000000..f7dd963 Binary files /dev/null and b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_close_icon.imageset/close.pdf differ 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 0000000..1186605 Binary files /dev/null and b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674.png differ 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 0000000..3af4933 Binary files /dev/null and b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@2x.png differ diff --git a/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@3x.png b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@3x.png new file mode 100644 index 0000000..9bd468f Binary files /dev/null and b/Sources/OnlineConsultant/Resources/Assets.xcassets/consultant_contact_avatar.imageset/Group 674@3x.png differ 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 {} +}