diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a18c770..598504d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,49 @@ jobs: ${{ runner.temp }}/pod-install.log ${{ runner.temp }}/pod-build.log + hosted_tests: + name: Tests / TestHost + needs: builds + runs-on: macos-26 + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Select iPhone simulator + id: simulator + run: python3 .github/scripts/select-simulator.py + + - name: Run application-hosted push tests + shell: bash + env: + SIMULATOR_ID: ${{ steps.simulator.outputs.device_id }} + run: | + set -euo pipefail + xcodebuild \ + -project TestHost/SDKTestHost.xcodeproj \ + -scheme SDKHostedTests \ + -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + -derivedDataPath "$RUNNER_TEMP/SimlaHostedDerivedData" \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/SimlaSourcePackages" \ + -parallel-testing-enabled NO \ + -resultBundlePath "$RUNNER_TEMP/SDKHostedTests.xcresult" \ + test CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee "$RUNNER_TEMP/SDKHostedTests.log" + + - name: Upload application-hosted test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: tests-TestHost + retention-days: 7 + if-no-files-found: ignore + path: | + ${{ runner.temp }}/SDKHostedTests.xcresult + ${{ runner.temp }}/SDKHostedTests.log + module_tests: name: Tests / ${{ matrix.module }} needs: builds @@ -148,6 +191,7 @@ jobs: timeout-minutes: 20 strategy: fail-fast: false + max-parallel: 1 matrix: include: - module: Core diff --git a/.gitignore b/.gitignore index deb0237..22d5869 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .swiftpm/ Package.resolved .DS_Store +/sample-shop/ diff --git a/NOTICE b/NOTICE index 7dbac18..669796c 100644 --- a/NOTICE +++ b/NOTICE @@ -1,27 +1,2 @@ Simla iOS SDK Copyright 2026 DIGITAL RETAIL TECHNOLOGIES, S.L. - -The PushService module uses Firebase iOS SDK (FirebaseCore, -FirebaseMessaging and FirebaseInstallations), licensed under -the Apache License 2.0. - -Source repository: https://github.com/firebase/firebase-ios-sdk - -Its transitive dependencies include: - -GoogleUtilities — Apache License 2.0 -https://github.com/google/GoogleUtilities - -GoogleDataTransport — Apache License 2.0 -https://github.com/google/GoogleDataTransport - -Google Promises — Apache License 2.0 -https://github.com/google/promises - -nanopb — zlib License -Copyright (c) 2011 Petteri Aimonen -https://github.com/firebase/nanopb - -IsAppEncrypted, included in GoogleUtilities — MIT License -Copyright (c) 2017 Landon J. Fuller landon@landonf.org -All rights reserved. \ No newline at end of file diff --git a/Package.swift b/Package.swift index a91702a..315696f 100644 --- a/Package.swift +++ b/Package.swift @@ -34,9 +34,6 @@ let package = Package( 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"), @@ -63,9 +60,6 @@ let package = Package( "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") diff --git a/SimlaPushService.podspec b/SimlaPushService.podspec index 07a8627..e48359b 100644 --- a/SimlaPushService.podspec +++ b/SimlaPushService.podspec @@ -18,8 +18,5 @@ Pod::Spec.new do |s| 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/Sources/PushService/Components/PushLocalization.swift b/Sources/PushService/Components/PushLocalization.swift index d4ee148..9acae69 100644 --- a/Sources/PushService/Components/PushLocalization.swift +++ b/Sources/PushService/Components/PushLocalization.swift @@ -8,7 +8,6 @@ package enum PushLocalization: String { 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" diff --git a/Sources/PushService/CrmSdk+PushService.swift b/Sources/PushService/CrmSdk+PushService.swift index b26374d..7b4abc6 100644 --- a/Sources/PushService/CrmSdk+PushService.swift +++ b/Sources/PushService/CrmSdk+PushService.swift @@ -2,7 +2,7 @@ import Core public extension CrmSdk { - /// Shared push module for applications that manage Firebase and notification delegates. + /// Shared push module that registers application-supplied tokens and handles notification events. var pushService: PushService { sharedPushService(for: .direct) } diff --git a/Sources/PushService/Models/PushServiceError.swift b/Sources/PushService/Models/PushServiceError.swift index 0531b93..dbad01e 100644 --- a/Sources/PushService/Models/PushServiceError.swift +++ b/Sources/PushService/Models/PushServiceError.swift @@ -36,12 +36,9 @@ public enum PushServiceError: Error, Equatable, LocalizedError, Sendable { /// Firebase installation ID is empty after trimming whitespace. case emptyFID - /// Firebase project ID is unavailable from configured Firebase options. + /// The Firebase project ID supplied by the application is empty after trimming whitespace. case emptyFirebaseProjectID - /// Firebase Messaging installation ID delivery is disabled in app configuration. - case firebaseInstallationIDDisabled - /// Customer ID or external customer ID is missing. case missingCustomerIdentifier @@ -60,12 +57,6 @@ public enum PushServiceError: Error, Equatable, LocalizedError, Sendable { /// 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 { @@ -81,14 +72,8 @@ public enum PushServiceError: Error, Equatable, LocalizedError, Sendable { 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): @@ -138,16 +123,12 @@ public enum PushServiceError: Error, Equatable, LocalizedError, Sendable { 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: diff --git a/Sources/PushService/PushService.swift b/Sources/PushService/PushService.swift index 2665c24..7a59c19 100644 --- a/Sources/PushService/PushService.swift +++ b/Sources/PushService/PushService.swift @@ -1,17 +1,14 @@ 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. +/// Registers application-supplied push identifiers and categories and handles RetailCRM notifications. /// -/// 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. +/// The application configures Firebase and APNs, obtains FCM tokens or Firebase installation IDs, +/// and forwards notification delegate events. Supply each token with its Firebase project ID. public final class PushService: NSObject { /// Called on the main queue when a RetailCRM notification or button is opened. @@ -19,8 +16,9 @@ public final class PushService: NSObject { /// 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. + /// Reports automatic registration or unsubscription results on the main actor. + /// Includes parameter validation failures, such as an empty project ID, before any server request. + /// Assigning the handler, reusing a confirmed registration, and superseding a pending call emit no result. @MainActor public var onPushRegistrationResult: ((Result) -> Void)? /// Receives the current retry queue and future push-status retry updates for this site. @@ -41,7 +39,7 @@ public final class PushService: NSObject { private let pushHandler: PushHandler private let registrationStorage: PushRegistrationStorage - @MainActor private var pushToken: PushToken? + @MainActor private var pushRegistration: (token: PushToken, firebaseProjectId: String)? @MainActor private var sessionReadyForPushRegistration = false { didSet { scheduleAutomaticRegistration() @@ -68,7 +66,10 @@ public final class PushService: NSObject { } /// 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. + /// Waits for an application-supplied valid token and a connected session with a customer identifier, + /// then validates the supplied project ID. Call setToken before awaiting this method in the same task. + /// Returns after updating iOS categories and backend acceptance or reuse of a confirmed registration. + /// Throws registration and local storage errors; automatic attempt results also reach onPushRegistrationResult. @MainActor public func registerNotificationCategories(_ categories: [String]) async throws -> PushToken { try await withCheckedThrowingContinuation { continuation in @@ -76,29 +77,27 @@ public final class PushService: NSObject { } } - /// Sets an FCM token or Firebase installation ID obtained by the application. + /// Stores an application-supplied FCM token or Firebase installation ID together with its project ID. + /// Updating either value schedules a registration check. Registration waits for categories and a + /// connected session with a customer identifier. The project ID must be nonempty after trimming whitespace. + /// Reports registration results through onPushRegistrationResult and pending category registration calls. /// /// - Parameters: /// - token: FCM token or Firebase Installation ID obtained by the application. + /// - firebaseProjectId: Firebase project ID associated with the token. @MainActor - public func setToken(_ token: PushToken) { - self.pushToken = token + public func setToken(_ token: PushToken, firebaseProjectId: String) { + self.pushRegistration = (token, firebaseProjectId) 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. + /// An empty list unsubscribes from all categories. Waits for an application-supplied valid token and + /// a connected customer session, then validates the supplied project ID. /// 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 @@ -135,30 +134,6 @@ public extension PushService { 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. @@ -325,7 +300,7 @@ private extension PushService { 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 pushRegistration, (try? pushRegistration.token.normalizedToken()) != nil, let identity, let savedCategories = categories else { return } let completions = pendingRegistrationCompletions pendingRegistrationCompletions.removeAll() @@ -337,8 +312,10 @@ private extension PushService { } catch { throw PushServiceError.invalidCustomerIdentity(error.localizedDescription) } - let token = try pushToken.normalizedToken() - let projectID = try resolvedFirebaseProjectId() + let token = try pushRegistration.token.normalizedToken() + guard let projectID = pushRegistration.firebaseProjectId.nilIfEmpty else { + throw PushServiceError.emptyFirebaseProjectID + } let categories = Array(Set(savedCategories)).sorted() let body = PushTokenRequestBody( customerId: validatedIdentity.customerId, @@ -386,77 +363,6 @@ private extension PushService { } } - @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 { diff --git a/Sources/PushService/Resources/en.lproj/Localizable.strings b/Sources/PushService/Resources/en.lproj/Localizable.strings index 0489057..09a35d8 100644 --- a/Sources/PushService/Resources/en.lproj/Localizable.strings +++ b/Sources/PushService/Resources/en.lproj/Localizable.strings @@ -3,7 +3,6 @@ "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: %@"; diff --git a/Sources/PushService/Resources/es.lproj/Localizable.strings b/Sources/PushService/Resources/es.lproj/Localizable.strings index 563876d..ecd47fa 100644 --- a/Sources/PushService/Resources/es.lproj/Localizable.strings +++ b/Sources/PushService/Resources/es.lproj/Localizable.strings @@ -3,7 +3,6 @@ "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: %@"; diff --git a/Sources/PushService/Resources/ru.lproj/Localizable.strings b/Sources/PushService/Resources/ru.lproj/Localizable.strings index 1b0d6b0..df1622d 100644 --- a/Sources/PushService/Resources/ru.lproj/Localizable.strings +++ b/Sources/PushService/Resources/ru.lproj/Localizable.strings @@ -3,7 +3,6 @@ "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-токена: %@"; diff --git a/TestHost/README.md b/TestHost/README.md index c0527a5..e1edd79 100644 --- a/TestHost/README.md +++ b/TestHost/README.md @@ -1,19 +1,28 @@ # Тесты SDK внутри iOS-приложения -Проект `SDKTestHost.xcodeproj` запускает все Swift-тесты из `../Tests` внутри минимального приложения. -Файлы тестов подключаются автоматически через синхронизированную группу Xcode. -Приложение требуется для `UNUserNotificationCenter` и тестов автоматической регистрации push. +Проект `SDKTestHost.xcodeproj` запускает push-тесты, которым нужен процесс iOS-приложения для работы с `UNUserNotificationCenter`. +В тестовую цель явно подключены существующие файлы из `../Tests`: + +- `PushServiceTests/PushAutomaticRegistrationTests.swift` — автоматическая регистрация токена и управление категориями. +- `PushServiceTests/PushRetryTestStorage.swift` — вспомогательное хранилище для изоляции тестов. +- `PushNotificationExtensionTests/PushNotificationExtensionTests.swift` — обработка уведомлений, включая регистрацию категорий с кнопками. + +Новые тестовые файлы, требующие приложения, нужно добавлять в цель `SDKHostedTests`. Запуск из корня репозитория: ```sh xcodebuild test \ - -project sdk/TestHost/SDKTestHost.xcodeproj \ + -project 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 сценариев, требующих приложения, в нём пропускаются. +Тесты выполняются последовательно, поскольку используют общие настройки SDK и системные категории уведомлений. +Обычный запуск схемы пакета `ConsultantSdk-Package` сохраняется; сценарии, требующие приложения, в нём пропускаются. + +В CI задача `Tests / TestHost` собирает приложение и запускает его тесты на симуляторе. +Обычные тесты модулей выполняются в отдельных задачах `Tests / <модуль>`. +Логи и результат запуска хостовых тестов сохраняются в артефакте `tests-TestHost`. diff --git a/TestHost/SDKTestHost.xcodeproj/project.pbxproj b/TestHost/SDKTestHost.xcodeproj/project.pbxproj index 8121b22..4a327a8 100644 --- a/TestHost/SDKTestHost.xcodeproj/project.pbxproj +++ b/TestHost/SDKTestHost.xcodeproj/project.pbxproj @@ -7,13 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 02CB27B609B3626593505F2D /* Tracker in Frameworks */ = {isa = PBXBuildFile; productRef = 67B83A8ECAC7A5575B06B0C4 /* Tracker */; }; + B10000000000000000000001 /* PushAutomaticRegistrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000001 /* PushAutomaticRegistrationTests.swift */; }; + B10000000000000000000002 /* PushRetryTestStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000002 /* PushRetryTestStorage.swift */; }; + B10000000000000000000003 /* PushNotificationExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000003 /* PushNotificationExtensionTests.swift */; }; 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 */ @@ -29,22 +29,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + F10000000000000000000001 /* PushAutomaticRegistrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushServiceTests/PushAutomaticRegistrationTests.swift; sourceTree = ""; }; + F10000000000000000000002 /* PushRetryTestStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushServiceTests/PushRetryTestStorage.swift; sourceTree = ""; }; + F10000000000000000000003 /* PushNotificationExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationExtensionTests/PushNotificationExtensionTests.swift; sourceTree = ""; }; 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; @@ -52,11 +45,8 @@ 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; }; @@ -71,6 +61,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9D6D2C628BC031B8A31FC4B6 /* ../Tests */ = { + isa = PBXGroup; + children = ( + F10000000000000000000001 /* PushAutomaticRegistrationTests.swift */, + F10000000000000000000002 /* PushRetryTestStorage.swift */, + F10000000000000000000003 /* PushNotificationExtensionTests.swift */, + ); + path = ../Tests; + sourceTree = ""; + }; 1E1EF0A52643A2DAF1C2CCDB /* Products */ = { isa = PBXGroup; children = ( @@ -139,17 +139,11 @@ 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 */; @@ -210,6 +204,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B10000000000000000000001 /* PushAutomaticRegistrationTests.swift in Sources */, + B10000000000000000000002 /* PushRetryTestStorage.swift in Sources */, + B10000000000000000000003 /* PushNotificationExtensionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,7 +239,6 @@ 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"; @@ -315,7 +311,6 @@ 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 = ( @@ -339,7 +334,6 @@ 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"; @@ -420,7 +414,6 @@ 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 = ( @@ -476,26 +469,11 @@ /* 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 ".." */; diff --git a/Tests/PushServiceTests/PushAutomaticRegistrationTests.swift b/Tests/PushServiceTests/PushAutomaticRegistrationTests.swift index 984c259..5e7ef21 100644 --- a/Tests/PushServiceTests/PushAutomaticRegistrationTests.swift +++ b/Tests/PushServiceTests/PushAutomaticRegistrationTests.swift @@ -1,4 +1,3 @@ -import FirebaseCore import Foundation import XCTest import UserNotifications @@ -8,40 +7,16 @@ import UserNotifications @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 { @@ -75,7 +50,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: blocker) } databaseURL = blocker.appendingPathComponent("registration.sqlite") let service = service() - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) do { _ = try await service.registerNotificationCategories(["orders"]) @@ -95,7 +70,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { } await settle() XCTAssertEqual(PushRegistrationURLProtocol.count, 0) - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") await settle() XCTAssertEqual(PushRegistrationURLProtocol.count, 0) try connect(service) @@ -105,7 +80,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { func testNoRequestUntilIntegratorSuppliesListIncludingEmpty() async throws { let service = service() - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) await settle() XCTAssertEqual(PushRegistrationURLProtocol.count, 0) @@ -116,7 +91,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { func testFullListsReplaceCategoriesAndEmptyUnsubscribes() async throws { let service = service() - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) _ = try await service.registerNotificationCategories(["orders", "news"]) _ = try await service.registerNotificationCategories(["news"]) @@ -129,7 +104,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { func testCategoryOrderAndDuplicatesDoNotCauseRequests() async throws { let service = service() - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) _ = try await service.registerNotificationCategories(["orders", "news"]) _ = try await service.registerNotificationCategories(["news", "orders", "news"]) @@ -139,12 +114,12 @@ final class PushAutomaticRegistrationTests: XCTestCase { func testRestartSkipsConfirmedRegistrationAndUnsubscription() async throws { for categories in [["orders"], []] { let first = service() - first.setToken(.fcm("A")) + first.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(first) _ = try await first.registerNotificationCategories(categories) let beforeRestart = PushRegistrationURLProtocol.count let restarted = service() - restarted.setToken(.fcm("A")) + restarted.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(restarted) _ = try await restarted.registerNotificationCategories(categories) XCTAssertEqual(PushRegistrationURLProtocol.count, beforeRestart) @@ -158,13 +133,13 @@ final class PushAutomaticRegistrationTests: XCTestCase { let unrelatedID = "unrelated-\(UUID().uuidString)" await addSystemCategory(unrelatedID) let first = service() - first.setToken(.fcm("A")) + first.setToken(.fcm("A"), firebaseProjectId: "test-firebase") 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")) + restarted.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(restarted) _ = try await restarted.registerNotificationCategories([retainedID, newID]) await waitForSystemCategories { @@ -180,7 +155,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { func testFailedListIsRetriedAfterRestart() async throws { let service = service() - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) _ = try await service.registerNotificationCategories(["orders"]) PushRegistrationURLProtocol.statusCode = 500 @@ -190,7 +165,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { } catch { } PushRegistrationURLProtocol.statusCode = 204 let restarted = self.service() - restarted.setToken(.fcm("A")) + restarted.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(restarted) _ = try await restarted.registerNotificationCategories(["news"]) _ = try await restarted.registerNotificationCategories(["news"]) @@ -203,7 +178,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { let failedID = "failed-\(UUID().uuidString)" let newID = "new-\(UUID().uuidString)" let first = service() - first.setToken(.fcm("A")) + first.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(first) _ = try await first.registerNotificationCategories([oldID]) PushRegistrationURLProtocol.statusCode = 500 @@ -214,7 +189,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { await waitForSystemCategories { !$0.contains(oldID) && $0.contains(failedID) } PushRegistrationURLProtocol.statusCode = 204 let restarted = service() - restarted.setToken(.fcm("A")) + restarted.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(restarted) _ = try await restarted.registerNotificationCategories([newID]) await waitForSystemCategories { !$0.contains(oldID) && !$0.contains(failedID) && $0.contains(newID) } @@ -238,7 +213,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { XCTAssertEqual(try? result.get(), .fcm("A")) latest.fulfill() } - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) await fulfillment(of: [superseded, latest], timeout: 2) XCTAssertEqual(PushRegistrationURLProtocol.count, 1) @@ -255,7 +230,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { XCTAssertNotNil(try? result.get()) firstCompleted.fulfill() } - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) await fulfillment(of: [started], timeout: 2) PushRegistrationURLProtocol.onRequest = nil @@ -275,18 +250,71 @@ final class PushAutomaticRegistrationTests: XCTestCase { (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) + service.setToken(token, firebaseProjectId: project) try connect(service) _ = try await service.registerNotificationCategories(["orders"]) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["type"] as? String, project) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["token"] as? String, token.fcmValue) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["fid"] as? String, token.fidValue) + } + XCTAssertEqual(PushRegistrationURLProtocol.count, configurations.count) + } + + func testEmptyProjectIDFailsWithoutRequestAndCanBeCorrected() async throws { + let service = service() + try connect(service) + for project in ["", " \n\t "] { + service.setToken(.fcm("A"), firebaseProjectId: project) + do { + _ = try await service.registerNotificationCategories(["orders"]) + XCTFail("Expected an empty project ID error") + } catch { + XCTAssertEqual(error as? PushServiceError, .emptyFirebaseProjectID) + } + } + XCTAssertEqual(PushRegistrationURLProtocol.count, 0) + + service.setToken(.fcm("A"), firebaseProjectId: "corrected-project") + _ = try await service.registerNotificationCategories(["orders"]) + XCTAssertEqual(PushRegistrationURLProtocol.count, 1) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["type"] as? String, "corrected-project") + } + + func testProjectIDIsTrimmedForRequestAndCachedRegistration() async throws { + let service = service() + service.setToken(.fcm("A"), firebaseProjectId: " \n project-id \t ") + try connect(service) + _ = try await service.registerNotificationCategories(["orders"]) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["type"] as? String, "project-id") + + service.setToken(.fcm("A"), firebaseProjectId: "project-id") + _ = try await service.registerNotificationCategories(["orders"]) + XCTAssertEqual(PushRegistrationURLProtocol.count, 1) + } + + func testTokenAndProjectUpdatesOnSameServiceCauseMatchingRequests() async throws { + let service = service() + try connect(service) + let configurations: [(PushToken, String)] = [ + (.fcm("A"), "first-project"), + (.fcm("A"), "second-project"), + (.fid("B"), "third-project") + ] + for (token, project) in configurations { + service.setToken(token, firebaseProjectId: project) + let registeredToken = try await service.registerNotificationCategories(["orders"]) + XCTAssertEqual(registeredToken, token) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["type"] as? String, project) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["token"] as? String, token.fcmValue) + XCTAssertEqual(PushRegistrationURLProtocol.lastBody?["fid"] as? String, token.fidValue) } XCTAssertEqual(PushRegistrationURLProtocol.count, configurations.count) } func testGlobalCallbackReportsRotatedTokenButNotCachedList() async throws { let service = service() - service.setToken(.fcm("A")) + service.setToken(.fcm("A"), firebaseProjectId: "test-firebase") try connect(service) _ = try await service.registerNotificationCategories(["orders"]) let updated = expectation(description: "Updated token reported") @@ -294,7 +322,7 @@ final class PushAutomaticRegistrationTests: XCTestCase { XCTAssertEqual(try? result.get(), .fcm("B")) updated.fulfill() } - service.setToken(.fcm("B")) + service.setToken(.fcm("B"), firebaseProjectId: "test-firebase") await fulfillment(of: [updated], timeout: 2) let cached = expectation(description: "Cached list emits no event") cached.isInverted = true @@ -343,6 +371,7 @@ 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 lastBody: [String: Any]? { lock.withLock { bodies.last } } static var lastCategories: [String]? { lock.withLock { bodies.last?["categories"] as? [String] } } private static var responseStatus = 204 static var statusCode: Int { diff --git a/docs/en/push-firebase.md b/docs/en/push-firebase.md index cc0cb86..5f55106 100644 --- a/docs/en/push-firebase.md +++ b/docs/en/push-firebase.md @@ -1,6 +1,8 @@ -# Push notifications through Firebase +# Push notifications -The `push-firebase` module integrates Simla push notifications through Firebase Cloud Messaging: device registration, notification display, and status reporting. +The `PushService` module registers application-supplied push tokens and notification categories with the server, +handles Simla notifications, and reports notification interaction statuses. +The application configures Firebase and APNs, obtains an FCM token or FID, and supplies it to the SDK together with the Firebase project ID. ## Contents @@ -8,7 +10,7 @@ The `push-firebase` module integrates Simla push notifications through Firebase - [Setup](#setup) - [Customer identification](#customer-identification) - [Providing a device token](#providing-a-device-token) -- [Registering a push token and categories](#registering-a-push-token-and-categories) +- [Registering categories](#registering-categories) - [Handling notifications](#handling-notifications) - [Handling notifications when the application is in the background or closed](#handling-notifications-when-the-application-is-in-the-background-or-closed) - [Navigation and notification data](#navigation-and-notification-data) @@ -41,9 +43,20 @@ See the [role description in the Firebase documentation](https://firebase.google ## Setup -Configure Firebase in the application using the [Firebase instructions](https://firebase.google.com/docs/ios/setup). +Add the required Firebase modules directly to the application and configure Firebase using the [official instructions](https://firebase.google.com/docs/ios/setup). +The application configures Firebase, obtains an FCM token or Firebase Installation ID, registers with APNs, and assigns notification delegates. +Supply the FCM token or FID together with the Firebase project ID through `setToken(_:firebaseProjectId:)` and notification categories through `registerNotificationCategories(_:completion:)`. + +Registration waits until all conditions are met: + +- Categories have been supplied through `registerNotificationCategories(_:completion:)`. +- A nonempty token has been supplied through `setToken(_:firebaseProjectId:)`. +- The SDK session is connected with a customer identifier. + +The calls can arrive in either order: registration starts automatically when all required data is available. -Call `configurePush()` immediately after `CrmSdk.initialize(...)`. Registration waits for the session and token. +Call `configurePush()` immediately after `CrmSdk.initialize(...)`. +Registration waits for categories, a token with its project ID, and a connected session with a customer identifier. ```swift @@ -74,11 +87,6 @@ private func configurePush() { } ``` -The application configures Firebase, obtains an FCM token or Firebase Installation ID, registers with APNs, and assigns notification delegates. - -Firebase must be configured before all conditions for applying the subscription are met. -To register a token, the SDK reads the Firebase project ID from `FirebaseApp.app()?.options.projectID`. - ## Customer identification Notify the SDK of sign-in, account changes, and sign-out through `CrmSdk.shared.setCustomerIdentity(...)`. @@ -86,17 +94,19 @@ See [Customer identification](./sdk.md#customer-identification). ## Providing a device token -Pass the device token through `setToken(_:)`. For FCM, assign `Messaging.messaging().delegate = self` in `AppDelegate` after configuring Firebase and calling `CrmSdk.initialize`. -Use the appropriate token retrieval variant: +Pass an FCM token or FID together with the project ID through `setToken(_:firebaseProjectId:)` on the main actor. +Assign `Messaging.messaging().delegate = self` after configuring Firebase, initializing the SDK, and assigning `onPushRegistrationResult`. +Supply the current value on each application launch and whenever the token changes. + +For FCM, use the following handler: ```swift import UIKit import Core import PushService +import FirebaseCore import FirebaseMessaging -import FirebaseInstallations -// When using FCM extension AppDelegate: MessagingDelegate { nonisolated func messaging( _ messaging: Messaging, @@ -104,89 +114,69 @@ extension AppDelegate: MessagingDelegate { ) { guard let fcmToken, !fcmToken.isEmpty else { return } Task { @MainActor in - CrmSdk.shared.pushService.setToken(.fcm(fcmToken)) + guard let projectID = FirebaseApp.app()?.options.projectID else { + print("Firebase project ID is missing") + return + } + CrmSdk.shared.pushService.setToken(.fcm(fcmToken), firebaseProjectId: projectID) } } } +``` -// When using FID -extension AppDelegate { - func application( - _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data - ) { - Messaging.messaging().apnsToken = deviceToken +For FID, set `FirebaseMessagingInstallationIdEnabled = YES` in the application's `Info.plist` and use this handler instead of the FCM variant above: - Messaging.messaging().register { error in - guard error == nil else { - // Handle the registration error. +```swift +extension AppDelegate: MessagingDelegate { + nonisolated func messaging( + _ messaging: Messaging, + didReceiveRegistration installationId: String? + ) { + guard let installationId, !installationId.isEmpty else { return } + Task { @MainActor in + guard let projectID = FirebaseApp.app()?.options.projectID else { + print("Firebase project ID is missing") return } - - Installations.installations().installationID { installationId, error in - guard error == nil, let installationId else { - // Handle the FID retrieval error. - return - } - Task { @MainActor in - CrmSdk.shared.pushService.setToken(.fid(installationId)) - } - } + CrmSdk.shared.pushService.setToken(.fid(installationId), firebaseProjectId: projectID) } } } ``` -Instead of `pushService.setToken()`, you can call `pushService.setDeviceToken(_:completion:)` to have the SDK obtain an FCM token or Firebase Installation ID through Firebase. -The method assigns `Messaging.messaging().apnsToken = deviceToken`. -If Firebase Installation ID is enabled, the SDK calls `Messaging.messaging().register(...)` and retrieves the FID through `Installations.installations().installationID(...)`; otherwise, it requests an FCM token through `Messaging.messaging().token(...)`. +The application registers with APNs and associates the APNs token with Firebase: ```swift func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - CrmSdk.shared.pushService.setDeviceToken(deviceToken) { result in - switch result { - case .success(let token): - print("Token: \(token)") - case .failure(let error): - print("Failed to retrieve the token: \(error)") - } - } + Messaging.messaging().apnsToken = deviceToken } ``` -Differences between the methods: - -- `setToken` stores the supplied value in memory and starts applying the subscription when categories and the session are ready. -- `setDeviceToken` additionally passes the APNs token to Firebase and retrieves an FCM token or FID, which it then uses for registration. - -When using only `setToken`, the application is responsible for configuring Firebase, registering with APNs, and associating the APNs token with Firebase. -For FID, the application must also perform Firebase Messaging registration, which the SDK calls in `setDeviceToken`. - -## Registering a push token and categories +## Registering categories Use `registerNotificationCategories(_:completion:)` to register a push token and CRM notification categories. The method performs the following steps: 1. Removes duplicates and sorts codes: `["news", "sale", "news"]` becomes `["news", "sale"]`. -2. Immediately stores the desired list in memory and UserDefaults. The confirmed list and parameter fingerprint are saved in `UserDefaults` after a successful server response, separately for each `siteToken`. The fingerprint includes the token, customer identifiers, Firebase project, and categories. Matching parameters prevent duplicate requests, including after an application restart. This check applies to registration and unsubscription; category order and duplicate codes do not affect comparison. +2. Immediately stores the desired list in memory and a local SQLite database. The confirmed list and parameter fingerprint are saved in the same database after a successful server response, separately for each `siteToken`. The fingerprint includes the token, customer identifiers, Firebase project, and categories. Matching parameters prevent duplicate requests, including after an application restart. This check applies to registration and unsubscription; category order and duplicate codes do not affect comparison. 3. Updates categories in `UNUserNotificationCenter`: creates categories with the same identifiers, no buttons, and `.customDismissAction` to handle dismissals. Previously SDK-managed categories absent from the new list are removed. Other application categories are preserved, but a matching identifier causes the category to be replaced. -4. Waits for a valid push token and a connected session with a customer identifier, then checks the Firebase project ID. +4. Waits for a valid push token supplied through `setToken(_:firebaseProjectId:)` and a connected session with a customer identifier. It then validates the supplied project ID: an empty or whitespace-only value produces `emptyFirebaseProjectID` without a server request. 5. Sends the category list, token, and customer identifiers to the server. If a different list arrives before the request starts, the previous pending call completes with `PushServiceError.operationSuperseded`. The application maintains the current CRM notification category list and passes it on every launch and subscription change. Pass `[]` to disable subscriptions, or the desired categories to enable them. -The SDK applies the current category list when the token, categories, or session state changes. +The SDK applies the current category list when the token, project ID, categories, or session state changes. Automatic registration does not occur until the first `registerNotificationCategories` call in the current run. On failure, the supplied list and local categories are retained; the confirmed list remains unchanged. Before sending a new request, the previous fingerprint is cleared: the server may have processed a request even if it returned an error. There are no timer-based retries: another attempt can occur on a new token or session event, or another call to `registerNotificationCategories`. -On the main thread, assign a result handler and supply the current category list and token. +On the main actor, assign a result handler and supply the current category list and token together with the project ID. The SDK performs registration automatically. The method saves data on the Online Consultant backend. The backend transfers registered tokens and categories to Simla through a background job at the start of each hour. @@ -430,11 +420,10 @@ pushService.onNotificationOpen = { action in - `onStatusRetryUpdate: ((PushStatusRetryUpdate) -> Void)?` — reports queued push statuses, retry requests, scheduled attempts, success, and retry termination off the main thread. Assignment emits `pending` snapshots for existing entries of the current site. Initial requests that never enter the retry queue are not published. Assign `nil` to unsubscribe. Events and their parameters are described in [Push notification analytics](#push-notification-analytics). - `onPushRegistrationResult: ((Result) -> Void)?` — reports registration or unsubscription results on the main thread, including parameter validation errors. The event is not emitted when assigning the handler, skipping an already confirmed state, or replacing a pending call with `operationSuperseded`. Success confirms server acceptance of the parameters, not notification delivery. - `registerNotificationCategories(_:)` accepts the complete desired list of CRM category codes. Call it on every application launch and subscription change. An empty list `[]` unsubscribes from all categories. The SDK waits for a valid token and a connected session with a customer identifier; it checks the Firebase project ID before sending. The SDK registers matching iOS categories with `.customDismissAction` and removes previously managed categories absent from the new list. Other application categories are preserved. If a code matches an application category, the SDK replaces that category and its actions. Use distinct codes. Removing an iOS category does not block notification delivery or change system authorization. The method is available as `async throws -> PushToken` and with `completion: (Result) -> Void`. It returns after local categories are updated and the server accepts the parameters, a saved confirmation is used, or an error occurs. -- `setToken(_ token: PushToken)` — accepts `.fcm(String)` or `.fid(String)`. -- `setDeviceToken(_:) async throws -> PushToken` and the `completion` variant — accept an APNs token and obtain an FCM token or Firebase Installation ID through Firebase. +- `setToken(_ token: PushToken, firebaseProjectId: String)` — accepts `.fcm(String)` or `.fid(String)` together with the Firebase project ID. Stores both values in memory; updating the token or project ID triggers a registration check when categories and a session with a customer identifier are ready. An empty project ID produces `emptyFirebaseProjectID` once the other data is ready. The method returns no result; registration errors arrive through `onPushRegistrationResult` and a pending `registerNotificationCategories` call. - `handleWillPresent(_:withCompletionHandler:completion:) -> Bool` — reports `.delivered` for a notification received while the application is open. - `handleDidReceive(_:withCompletionHandler:completion:) -> Bool` — reports `.clicked` or `.swiped` for a user action. -- `onNotificationOpen: ((NotificationAction) -> Void)?` — passes the original navigation string (`urlString`) to the application. Tapping the notification provides the navigation link and custom attributes if specified. Tapping a button provides only the navigation link. The SDK does not open the link. +- `onNotificationOpen: ((NotificationAction) -> Void)?` — passes the original navigation string (`urlString`) to the application. For notification taps, `urlString` contains the navigation string; for button taps, it contains the button's `value`. Notification attributes are supplied through `customAttributes`, when available, in both cases. Dismissing a notification does not invoke the handler. The application performs navigation. - `handleSilentNotification(_:fetchCompletionHandler:completion:) -> Bool` — reports `.delivered` for a silent Simla notification. - `PushService.version: String` — module version. @@ -458,10 +447,10 @@ for automatic retry. There are up to five total attempts with delays of `10`, `2 seconds; `Retry-After` may extend the delay. The original `changed_at` and `button_hash` are preserved. Other HTTP errors and encoding failures are not retried. -Queue entries are serialized as JSON in `UserDefaults.standard`, separately for the application -and notification extension. Retries run while the respective process is active and the network is -available. After termination, the queue resumes when -`PushService` or `PushNotificationHandler` is created in that process. iOS does not guarantee retries +The SDK saves statuses awaiting retry in a local SQLite database. +Retries run while the process hosting the SDK handler is active and the network is available. +When `PushService` or `PushNotificationHandler` is created again, the SDK resumes processing the persisted queue available to it. +iOS does not guarantee retries while the application is suspended or the extension has terminated. `completion` is called once with the first attempt's result, after a transient failure is saved to @@ -509,13 +498,14 @@ Public `PushService` operations pass this type to `completion`. Denial of notifi - `authorizationDenied` — the user denied notification authorization. - `operationSuperseded` — a pending call was replaced by another category list before the request started. - `emptyPushToken` — the push token is missing; `registerNotificationCategories` waits for it to become available before sending. -- `emptyFCMToken`, `emptyFID`, `firebaseMessaging(Error)`, `firebaseInstallations(Error)`, `emptyFirebaseProjectID`, `firebaseInstallationIDDisabled` — token retrieval and Firebase configuration errors. +- `emptyFCMToken`, `emptyFID` — an empty FCM token or FID. Registration waits for a valid token. +- `emptyFirebaseProjectID` — the supplied project ID is empty after trimming whitespace and newlines. Once the other data is ready, the error arrives through `onPushRegistrationResult` and a pending `registerNotificationCategories` call (`completion` or the async version's `catch`); no server request is sent. The application handles Firebase configuration and token retrieval errors. - `invalidHTTPResponse(String)`, `requestEncodingFailed(String)`, `requestFailed(String)`, `httpStatus(Int, [String])` — server token registration errors. - `statusReporting(StatusReportingFailure)` — status reporting failure: `requestEncodingFailed(String)`, `invalidHTTPResponse(String)`, `requestFailed(String)`, or `httpStatus(Int, [String])`. ## AppDelegate implementation example -This example starts the SDK without a customer identity. After the customer signs in, call `CrmSdk.shared.setCustomerIdentity(_:)` and wait for successful completion. +This example uses FCM tokens. Firebase is added to and configured in the application. The SDK starts without a customer identity. After the customer signs in, call `CrmSdk.shared.setCustomerIdentity(_:)` and wait for successful completion. Provide `customerId`, or `siteCustomerId` together with `siteCode`. Until a customer identifier is supplied, push subscription registration waits for session readiness. See [Customer identification](./sdk.md#customer-identification). @@ -524,6 +514,8 @@ See [Customer identification](./sdk.md#customer-identification). import UIKit import Core import FirebaseCore +import FirebaseMessaging +import UserNotifications import PushService @main @@ -538,14 +530,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - CrmSdk.shared.pushService.setDeviceToken(deviceToken) { result in - switch result { - case .success(let token): - print("Token: \(token)") - case .failure(let error): - print("Failed to retrieve the token: \(error)") - } - } + Messaging.messaging().apnsToken = deviceToken } func application( @@ -576,8 +561,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func configureServices(_ application: UIApplication) { self.firebaseConfigure() - self.registerNootificationCenter(application) self.configureCrmSdk() + Messaging.messaging().delegate = self + self.registerNootificationCenter(application) } private func firebaseConfigure() { @@ -636,6 +622,23 @@ private extension AppDelegate { } } +// MessagingDelegate +extension AppDelegate: MessagingDelegate { + nonisolated func messaging( + _ messaging: Messaging, + didReceiveRegistrationToken fcmToken: String? + ) { + guard let fcmToken, !fcmToken.isEmpty else { return } + Task { @MainActor in + guard let projectID = FirebaseApp.app()?.options.projectID else { + print("Firebase project ID is missing") + return + } + CrmSdk.shared.pushService.setToken(.fcm(fcmToken), firebaseProjectId: projectID) + } + } +} + // UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/docs/en/sdk.md b/docs/en/sdk.md index 8555e6d..2502ef2 100644 --- a/docs/en/sdk.md +++ b/docs/en/sdk.md @@ -1,4 +1,4 @@ -# Simla iOS SDK +Simla iOS SDK The SDK adds online chat, customer activity tracking, product recommendations, and push notifications to an iOS application. @@ -28,7 +28,7 @@ Swift module / CocoaPods — Purpose `OnlineConsultant` / `SimlaOnlineConsultant` — Online chat `Tracker` / `SimlaTracker` — Customer activity tracking `Recommendations` / `SimlaRecommendations` — Product recommendations -`PushService` / `SimlaPushService` — Firebase push notifications, with Firebase and delegates managed by the application +`PushService` / `SimlaPushService` — FCM/FID registration and push notification handling; `PushNotificationExtension` / `SimlaPushNotificationExtension` — Notification processing in a Notification Service Extension The modules share the `Core` session. @@ -40,7 +40,7 @@ The modules share the `Core` session. - iOS 15 or later. - Online chat requires the Online Consultant module to be enabled in Simla. - Recommendations require a configured recommendation mechanic in Simla. -- Firebase push notifications require Firebase and APNs to be configured in the application. +- For push notifications, the application adds and configures Firebase and APNs, then supplies an FCM token or FID together with the Firebase project ID. ## Installation @@ -148,7 +148,7 @@ Replace the target names with those in your project, run `pod install`, and open `.xcworkspace`. The extension block is required only when a Notification Service Extension is present. Direct Git integration also requires listing the internal `SimlaSDKConfiguration` and `SimlaSQLiteStorage` pods with `sdk_source`. -CocoaPods resolves Firebase dependencies from the public specification repository. +Firebase is not an SDK dependency. For push notifications, add Firebase to the application separately — see [Push notifications](./push-firebase.md#setup). ## Initialization diff --git a/docs/ru/push-firebase.md b/docs/ru/push-firebase.md index 9a8d63e..708d6fb 100644 --- a/docs/ru/push-firebase.md +++ b/docs/ru/push-firebase.md @@ -1,7 +1,8 @@ -# Push-уведомления через Firebase +# Push-уведомления -Модуль `push-firebase` подключает push-уведомления RetailCRM через Firebase Cloud Messaging: -регистрацию устройства, отображение уведомлений и отправку статусов. +Модуль `PushService` регистрирует переданные приложением push-токены и категории уведомлений на сервере, +обрабатывает уведомления RetailCRM и отправляет статусы взаимодействия с ними. +Приложение самостоятельно настраивает Firebase и APNs, получает FCM-токен или FID и передаёт его SDK вместе с Firebase project ID. ## Оглавление @@ -9,7 +10,7 @@ - [Подключение](#подключение) - [Идентификация клиента](#идентификация-клиента) - [Передача токена устройства](#передача-токена-устройства) -- [Регистрация push-токена и категорий](#регистрация-push-токена-и-категорий) +- [Регистрация категорий](#регистрация-категорий) - [Обработка уведомлений](#обработка-уведомлений) - [Обработка уведомлений при свёрнутом или закрытом приложении](#обработка-уведомлений-при-свёрнутом-или-закрытом-приложении) - [Переходы и данные уведомления](#переходы-и-данные-уведомления) @@ -45,8 +46,20 @@ Firebase [поддерживает](https://firebase.google.com/docs/cloud-messa ## Подключение -Настройте Firebase в приложении по [инструкции Firebase](https://firebase.google.com/docs/ios/setup). -Вызывайте `configurePush()` сразу после `CrmSdk.initialize(...)`. Регистрация ожидает готовности сессии и токена. +Подключите необходимые модули Firebase непосредственно в приложение и настройте Firebase по [официальной инструкции](https://firebase.google.com/docs/ios/setup). +Настройка Firebase, получение FCM-токена или Firebase Installation ID, регистрация в APNs и назначение делегатов уведомлений выполняются в приложении. +Приложение передаёт готовый FCM-токен или FID вместе с идентификатором Firebase-проекта через `setToken(_:firebaseProjectId:)` и категории уведомлений через `registerNotificationCategories(_:completion:)`. + +Регистрация ждёт, пока будут выполнены все условия: + +- Переданы категории через `registerNotificationCategories(_:completion:)`. +- Установлен непустой токен через `setToken(_:firebaseProjectId:)`. +- Подключена сессия SDK с идентификатором клиента. + +Порядок вызовов не важен: когда появятся все данные, регистрация запустится автоматически. + +Вызывайте `configurePush()` сразу после `CrmSdk.initialize(...)`. +Регистрация ожидает передачи категорий, токена с project ID и готовности сессии с идентификатором клиента. ```swift @@ -77,11 +90,6 @@ private func configurePush() { } ``` -Настройка Firebase, получение FCM-токена или Firebase Installation ID, регистрация в APNs и назначение делегатов уведомлений выполняются в приложении. - -Firebase должен быть настроен до появления всех условий для применения подписки. -Для регистрации токена, SDK получает идентификатор Firebase-проекта из `FirebaseApp.app()?.options.projectID`. - ## Идентификация клиента Сообщайте SDK о входе, смене аккаунта и выходе клиента через `CrmSdk.shared.setCustomerIdentity(...)`. @@ -89,18 +97,19 @@ Firebase должен быть настроен до появления всех ## Передача токена устройства -Передайте токен устройства через `setToken(_:)`. Для FCM назначьте `Messaging.messaging().delegate = self` -в `AppDelegate` после настройки Firebase и вызова `CrmSdk.initialize`. -Используйте соответствующий вариант получения токена: +Передайте FCM-токен или FID вместе с project ID через `setToken(_:firebaseProjectId:)` на главном акторе. +Назначьте `Messaging.messaging().delegate = self` после настройки Firebase, инициализации SDK и назначения `onPushRegistrationResult`. +Передавайте актуальное значение при каждом запуске приложения и обновлении токена. + +Для FCM используйте следующий обработчик: ```swift import UIKit import Core import PushService +import FirebaseCore import FirebaseMessaging -import FirebaseInstallations -// При использовании FCM extension AppDelegate: MessagingDelegate { nonisolated func messaging( _ messaging: Messaging, @@ -108,86 +117,67 @@ extension AppDelegate: MessagingDelegate { ) { guard let fcmToken, !fcmToken.isEmpty else { return } Task { @MainActor in - CrmSdk.shared.pushService.setToken(.fcm(fcmToken)) + guard let projectID = FirebaseApp.app()?.options.projectID else { + print("Firebase project ID is missing") + return + } + CrmSdk.shared.pushService.setToken(.fcm(fcmToken), firebaseProjectId: projectID) } } } +``` -// При использовании FID -extension AppDelegate { - func application( - _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data - ) { - Messaging.messaging().apnsToken = deviceToken +Для FID включите `FirebaseMessagingInstallationIdEnabled = YES` в `Info.plist` приложения и используйте следующий обработчик вместо FCM-варианта выше: - Messaging.messaging().register { error in - guard error == nil else { - // Обработайте ошибку регистрации. +```swift +extension AppDelegate: MessagingDelegate { + nonisolated func messaging( + _ messaging: Messaging, + didReceiveRegistration installationId: String? + ) { + guard let installationId, !installationId.isEmpty else { return } + Task { @MainActor in + guard let projectID = FirebaseApp.app()?.options.projectID else { + print("Firebase project ID is missing") return } - - Installations.installations().installationID { installationId, error in - guard error == nil, let installationId else { - // Обработайте ошибку получения FID. - return - } - Task { @MainActor in - CrmSdk.shared.pushService.setToken(.fid(installationId)) - } - } + CrmSdk.shared.pushService.setToken(.fid(installationId), firebaseProjectId: projectID) } } } ``` - -Вместо вызова `pushService.setToken()` можно вызвать метод `pushService.setDeviceToken(_:completion:)`, чтобы SDK получил FCM-токен или Firebase Installation ID через Firebase. -Метод присваивает `Messaging.messaging().apnsToken = deviceToken`. -Если включён Firebase Installation ID, SDK вызывает `Messaging.messaging().register(...)` и получает FID через `Installations.installations().installationID(...)`, иначе запрашивает FCM-токен через `Messaging.messaging().token(...)`. +Регистрация в APNs и связь APNs-токена с Firebase выполняются в приложении: ```swift func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - CrmSdk.shared.pushService.setDeviceToken(deviceToken) { result in - switch result { - case .success(let token): - print("Токен: \(token)") - case .failure(let error): - print("Не удалось получить токен: \(error)") - } - } + Messaging.messaging().apnsToken = deviceToken } ``` -Разница между методами: -- setToken сохраняет переданное значение в памяти и запускает применение подписки, когда готовы категории и сессия. -- setDeviceToken дополнительно передаёт APNs-токен в Firebase и получает FCM-токен либо FID, который затем использует для регистрации. -При использовании только setToken приложение самостоятельно отвечает за настройку Firebase, регистрацию в APNs и связь APNs-токена с Firebase. -Для FID также нужно самостоятельно выполнить регистрацию Firebase Messaging, которую SDK вызывает в setDeviceToken. - -## Регистрация push-токена и категорий +## Регистрация категорий Для регистрации push-токена и категорий CRM уведомлений используйте метод `registerNotificationCategories(_:completion:)`. Метод выполняет следующие действия: 1. Удаляет дубли и сортирует коды: ["news", "sale", "news"] превращается в ["news", "sale"]. -2. Сразу сохраняет желаемый список в памяти и UserDefaults. Подтверждённый список и отпечаток параметров сохраняются в `UserDefaults` после успешного ответа сервера, отдельно для каждого `siteToken`. Отпечаток учитывает токен, идентификаторы клиента, Firebase-проект и категории. При совпадении параметров запрос не повторяется, в том числе после перезапуска приложения. Проверка работает для регистрации и отписки; порядок категорий и дубли одинаковых кодов не влияют на сравнение. +2. Сразу сохраняет желаемый список в памяти и локальной базе SQLite. Подтверждённый список и отпечаток параметров сохраняются в той же базе после успешного ответа сервера, отдельно для каждого `siteToken`. Отпечаток учитывает токен, идентификаторы клиента, Firebase-проект и категории. При совпадении параметров запрос не повторяется, в том числе после перезапуска приложения. Проверка работает для регистрации и отписки; порядок категорий и дубли одинаковых кодов не влияют на сравнение. 3. Обновляет категории в UNUserNotificationCenter: создаёт категории с такими же идентификаторами, без кнопок и с .customDismissAction для обработки смахивания. Ранее управляемые SDK категории, отсутствующие в новом списке, удаляются. Другие категории приложения сохраняются, но при совпадении идентификатора категория будет заменена. -4. Ждёт валидный push-токен и подключённую сессию с идентификатором клиента, затем проверяет Firebase project ID. +4. Ждёт валидный push-токен, переданный через `setToken(_:firebaseProjectId:)`, и подключённую сессию с идентификатором клиента. Затем проверяет переданный project ID: пустое или состоящее из пробелов значение приводит к `emptyFirebaseProjectID` без запроса на сервер. 5. Отправляет список категорий вместе с токеном и идентификаторами клиента на сервер. Если до начала запроса поступил другой список, прежний ожидающий вызов завершается с `PushServiceError.operationSuperseded`. Приложение само хранит актуальный список категорий CRM уведомлений и передаёт его при каждом запуске и изменении подписки. Для отключения передайте `[]`, для включения — нужные категории. -SDK применяет актуальный список категорий при изменении токена, категорий и состояния сессии. +SDK применяет актуальный список категорий при изменении токена, project ID, категорий и состояния сессии. До первого вызова `registerNotificationCategories` в текущем запуске автоматическая регистрация не выполняется. При ошибке, переданный список и локальные категории сохраняются, подтверждённый список не изменяется. Перед отправкой нового запроса старый отпечаток сбрасывается: если запрос завершился ошибкой, сервер мог успеть его обработать. Повторов по таймеру нет: следующая попытка возможна при новом событии токена или сессии либо повторном вызове `registerNotificationCategories`. -На главном потоке назначьте обработчик результата, передайте актуальный список категорий и токен. +На главном акторе назначьте обработчик результата, передайте актуальный список категорий и токен вместе с project ID. SDK выполнит регистрацию автоматически. Метод сохраняет данные на backend Онлайн-консультанта. Backend передаёт зарегистрированные токены и категории в RetailCRM фоновой задачей в начале каждого часа. @@ -431,11 +421,10 @@ pushService.onNotificationOpen = { action in - `onStatusRetryUpdate: ((PushStatusRetryUpdate) -> Void)?` — сообщает вне главного потока о постановке push-статусов в очередь, начале повторных запросов, назначении следующей попытки, успехе и прекращении повторов. При назначении передаёт `pending` для уже ожидающих записей текущего сайта. Первоначальные запросы, не попавшие в очередь, не публикуются. Присвоение `nil` отключает подписку. События и их параметры описаны в разделе [«Аналитика push-уведомлений»](#аналитика-push-уведомлений). - `onPushRegistrationResult: ((Result) -> Void)?` — сообщает на главном потоке результат регистрации или отписки, включая ошибки проверки параметров. При назначении обработчика, пропуске уже подтверждённого состояния и замене ожидающего вызова через `operationSuperseded` событие не вызывается. Успех подтверждает принятие параметров сервером, но не доставку уведомлений. - `registerNotificationCategories(_:)` принимает полный желаемый список кодов категорий CRM. Вызывайте метод при каждом запуске приложения и изменении подписки. Пустой список `[]` означает отписку от всех категорий. SDK ждёт валидный токен и подключённую сессию с идентификатором клиента; перед отправкой проверяет идентификатор Firebase-проекта. SDK регистрирует одноимённые категории iOS с `.customDismissAction` и удаляет ранее управляемые им категории, отсутствующие в новом списке. Остальные категории приложения сохраняются. Если код совпадает с категорией приложения, SDK заменит её вместе с действиями. Выбирайте непересекающиеся коды. Удаление категории iOS не блокирует доставку уведомлений и не меняет системное разрешение. Метод доступен как `async throws -> PushToken` и с `completion: (Result) -> Void`. Результат возвращается после обновления локальных категорий и принятия параметров сервером, использования сохранённого подтверждения либо ошибки. -- `setToken(_ token: PushToken)` — принимает `.fcm(String)` или `.fid(String)`. -- `setDeviceToken(_:) async throws -> PushToken` и вариант с `completion` — принимает APNs-токен и получает через Firebase FCM-токен или Firebase Installation ID. +- `setToken(_ token: PushToken, firebaseProjectId: String)` — принимает `.fcm(String)` или `.fid(String)` вместе с идентификатором Firebase-проекта. Сохраняет оба значения в памяти; смена токена или project ID запускает проверку регистрации при готовности категорий и сессии с идентификатором клиента. Пустой project ID приводит к `emptyFirebaseProjectID` при готовности остальных данных. Метод не возвращает результат; ошибки регистрации поступают в `onPushRegistrationResult` и ожидающий вызов `registerNotificationCategories`. - `handleWillPresent(_:withCompletionHandler:completion:) -> Bool` — отправляет `.delivered` для уведомления в открытом приложении. - `handleDidReceive(_:withCompletionHandler:completion:) -> Bool` — отправляет `.clicked` или `.swiped` при действии пользователя. -- `onNotificationOpen: ((NotificationAction) -> Void)?` — передаёт приложению исходную строку перехода (`urlString`). При нажатии на уведомление это ссылка перехода и дополнительные параметры если они заданы. При нажатии на кнопку это только ссылка перехода. SDK не открывает ссылку. +- `onNotificationOpen: ((NotificationAction) -> Void)?` — передаёт приложению исходную строку перехода (`urlString`). При нажатии на уведомление `urlString` содержит строку перехода, при нажатии на кнопку — её `value`. Атрибуты уведомления передаются через `customAttributes`, если они доступны, в обоих случаях. При смахивании обработчик не вызывается. Приложение самостоятельно выполняет переход. - `handleSilentNotification(_:fetchCompletionHandler:completion:) -> Bool` — отправляет `.delivered` для тихого уведомления RetailCRM. - `PushService.version: String` — версия модуля. @@ -459,10 +448,10 @@ SDK автоматически отправляет статусы взаимо секунд; `Retry-After` может увеличить задержку. Исходные `changed_at` и `button_hash` сохраняются. Остальные HTTP-ошибки и ошибки кодирования не повторяются. -Записи очереди сериализуются в JSON и сохраняются в `UserDefaults.standard` отдельно в приложении -и push-расширении. Повторы выполняются при доступной сети, пока соответствующий процесс работает; -после его остановки очередь возобновляется при создании -`PushService` или `PushNotificationHandler` в этом же процессе. iOS не гарантирует выполнение повторов +SDK сохраняет статусы, ожидающие повторной отправки, в локальной базе SQLite. +Повторы выполняются при доступной сети, пока работает процесс с обработчиком SDK. +При повторном создании `PushService` или `PushNotificationHandler` SDK возобновляет обработку доступной ему сохранённой очереди. +iOS не гарантирует выполнение повторов в приостановленном приложении или завершённом расширении. `completion` вызывается один раз с результатом первой попытки, после сохранения временной ошибки @@ -510,13 +499,14 @@ CrmSdk.shared.pushService.onStatusRetryUpdate = { update in - `authorizationDenied` — пользователь отказал в разрешении на уведомления. - `operationSuperseded` — ожидающий вызов заменён другим списком категорий до начала запроса. - `emptyPushToken` — push-токен отсутствует; `registerNotificationCategories` ожидает его готовности перед отправкой. -- `emptyFCMToken`, `emptyFID`, `firebaseMessaging(Error)`, `firebaseInstallations(Error)`, `emptyFirebaseProjectID`, `firebaseInstallationIDDisabled` — ошибки получения токенов и настройки Firebase. +- `emptyFCMToken`, `emptyFID` — пустой FCM-токен или FID. Регистрация ожидает передачи валидного токена. +- `emptyFirebaseProjectID` — переданный project ID пуст после удаления пробелов и переводов строк. При готовности остальных данных ошибка поступает в `onPushRegistrationResult` и ожидающий `registerNotificationCategories` (`completion` или `catch` у async-версии); запрос на сервер не отправляется. Ошибки получения токенов и настройки Firebase обрабатывает приложение. - `invalidHTTPResponse(String)`, `requestEncodingFailed(String)`, `requestFailed(String)`, `httpStatus(Int, [String])` — ошибки регистрации токена на сервере. - `statusReporting(StatusReportingFailure)` — ошибка отправки статуса: `requestEncodingFailed(String)`, `invalidHTTPResponse(String)`, `requestFailed(String)` или `httpStatus(Int, [String])`. ## Пример реализации AppDelegate -В примере SDK запускается без идентификатора клиента. После входа клиента вызовите +Пример использует FCM-токены. Firebase подключён и настроен в приложении. SDK запускается без идентификатора клиента. После входа клиента вызовите `CrmSdk.shared.setCustomerIdentity(_:)` и дождитесь успешного завершения. Передайте `customerId` либо `siteCustomerId` вместе с `siteCode`. До получения идентификатора клиента регистрация push-подписки ожидает готовности сессии. @@ -526,6 +516,8 @@ CrmSdk.shared.pushService.onStatusRetryUpdate = { update in import UIKit import Core import FirebaseCore +import FirebaseMessaging +import UserNotifications import PushService @main @@ -540,14 +532,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - CrmSdk.shared.pushService.setDeviceToken(deviceToken) { result in - switch result { - case .success(let token): - print("Токен: \(token)") - case .failure(let error): - print("Не удалось получить токен: \(error)") - } - } + Messaging.messaging().apnsToken = deviceToken } func application( @@ -578,8 +563,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func configureServices(_ application: UIApplication) { self.firebaseConfigure() - self.registerNootificationCenter(application) self.configureCrmSdk() + Messaging.messaging().delegate = self + self.registerNootificationCenter(application) } private func firebaseConfigure() { @@ -638,6 +624,23 @@ private extension AppDelegate { } } +// MessagingDelegate +extension AppDelegate: MessagingDelegate { + nonisolated func messaging( + _ messaging: Messaging, + didReceiveRegistrationToken fcmToken: String? + ) { + guard let fcmToken, !fcmToken.isEmpty else { return } + Task { @MainActor in + guard let projectID = FirebaseApp.app()?.options.projectID else { + print("Firebase project ID is missing") + return + } + CrmSdk.shared.pushService.setToken(.fcm(fcmToken), firebaseProjectId: projectID) + } + } +} + // UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { diff --git a/docs/ru/sdk.md b/docs/ru/sdk.md index fdd1912..d3f12ef 100644 --- a/docs/ru/sdk.md +++ b/docs/ru/sdk.md @@ -1,4 +1,4 @@ -# RetailCRM iOS SDK +RetailCRM iOS SDK SDK позволяет подключить к iOS-приложению онлайн-консультант, трекинг действий клиентов, товарные рекомендации и push-уведомления. @@ -29,7 +29,7 @@ Swift-модуль / CocoaPods - Назначение `OnlineConsultant` / `SimlaOnlineConsultant` - Онлайн-консультант `Tracker` / `SimlaTracker` - Трекинг действий клиентов `Recommendations` / `SimlaRecommendations` - Товарные рекомендации -`PushService` / `SimlaPushService` - Firebase push-уведомления, при управлении Firebase и делегатами со стороны приложения +`PushService` / `SimlaPushService` - Регистрация FCM/FID и обработка push-уведомлений; `PushNotificationExtension` / `SimlaPushNotificationExtension` - Обработка уведомлений в Notification Service Extension Модули используют общую сессию `Core`. @@ -42,7 +42,7 @@ Swift-модуль / CocoaPods - Назначение - Для использования онлайн-консультанта в RetailCRM должен быть подключён модуль «Онлайн-консультант». - Для рекомендаций нужна настроенная механика рекомендаций в RetailCRM. -- Для Firebase push-уведомлений нужны настройки Firebase и APNs в приложении. +- Для push-уведомлений приложение самостоятельно подключает и настраивает Firebase и APNs, затем передаёт SDK готовый FCM-токен или FID вместе с Firebase project ID. ## Подключение @@ -150,7 +150,7 @@ end `.xcworkspace`. Блок расширения нужен только при наличии Notification Service Extension. При прямом Git-подключении внутренние модули `SimlaSDKConfiguration` и `SimlaSQLiteStorage` тоже перечисляются с `sdk_source`. -Firebase-зависимости CocoaPods получает из публичного каталога. +Firebase не входит в зависимости SDK. Для push-уведомлений подключите Firebase к приложению отдельно — см. [инструкцию по push-уведомлениям](./push-firebase.md#подключение). ## Инициализация