diff --git a/documentation/internal/sdk-knowledge/native/ios.md b/documentation/internal/sdk-knowledge/native/ios.md index a1c382aea..28e464184 100644 --- a/documentation/internal/sdk-knowledge/native/ios.md +++ b/documentation/internal/sdk-knowledge/native/ios.md @@ -184,6 +184,15 @@ viewportHeight:)` from its own scroll/layout callbacks and the controller applie so typed native renderers can distinguish baseline and variant shapes after resolution. It does not validate fields or choose content-type-specific UI. source: extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +- `CTEntry` is constructible by consumers from all three entry shapes, matching + `CTEntry.from(...)` on Android: `init(_: Contentful.Entry)`, `init(dictionary:fallback:)`, and + `init(json:)`. The dictionary initializer is fail-soft — a value with no JSON representation logs + and yields `fallback`, defaulting to the public `CTEntry.empty` (no `sys`, no fields, every read + absent) — while `init(json:)` throws instead, since a malformed JSON string carries a decoding + error worth surfacing. SDK APIs still accept entries as dictionaries, not `CTEntry`, so these + initializers are for reading an entry through `getField`/`hasField` rather than for passing one + back into resolution. + source: extern:packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift; extern:packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/contentful/CTEntry.kt ## Identifier ownership diff --git a/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj b/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj index 603c21474..7b6ef24e7 100644 --- a/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj +++ b/implementations/ios-sdk/OptimizationApp.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 137A73B22636AC4E7B2E0EF0 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102D5A209AF6AC8B69E4BD24 /* AnalyticsTests.swift */; }; 19E3767A1A9F73198E5BD235 /* XCTestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9711C5717754C40619B585EE /* XCTestExtensions.swift */; }; 1A3EC2767577C51E53AB19D2 /* AnalyticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102D5A209AF6AC8B69E4BD24 /* AnalyticsTests.swift */; }; + 1FC8A89C845F30ACAD88586B /* Contentful in Frameworks */ = {isa = PBXBuildFile; productRef = E897BA8CDB1DDBF1C164A4E4 /* Contentful */; }; 244AA9A414B83F37FAE72F42 /* ContentEntryUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E433A2784433D5F7632A8098 /* ContentEntryUIView.swift */; }; 24EF9DE5BDCB8CF94011F6E4 /* UnidentifiedVariantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E8E4E3EC9B5BB52E93ECC8 /* UnidentifiedVariantsTests.swift */; }; 27E21567FB96A9E2EC57EE81 /* XCTestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9711C5717754C40619B585EE /* XCTestExtensions.swift */; }; @@ -28,10 +29,13 @@ 471F733790E71327EAA41CA8 /* LiveUpdatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85C1A5AF835283004FFE76D /* LiveUpdatesTests.swift */; }; 4FEBB65BC56EFBD45D81C803 /* PreviewPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCEDAABD36DADC093A8FD78 /* PreviewPanelTests.swift */; }; 53F94866D0BA3E09271D73F7 /* ContentfulOptimization in Frameworks */ = {isa = PBXBuildFile; productRef = B622890DF93C3F71C32F81AB /* ContentfulOptimization */; }; + 55EBF3F91D016BC49C2C077E /* MockContentfulTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26C4493D66695EEA5637A015 /* MockContentfulTransport.swift */; }; 58116AD3ED6029892390DFCE /* ScreenTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2A09744E23C92FDCD9F5439 /* ScreenTrackingTests.swift */; }; + 58EF6936C43184E5E5BB149B /* Contentful in Frameworks */ = {isa = PBXBuildFile; productRef = 223614F3D65269BC06E8BC2A /* Contentful */; }; 5C1226FB8DC19DE4D3783F11 /* UnidentifiedVariantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06E8E4E3EC9B5BB52E93ECC8 /* UnidentifiedVariantsTests.swift */; }; 5F679B3B001F53CD0A6BBC98 /* IdentifiedVariantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44D0B58FB5CDE1A33ADEC3E /* IdentifiedVariantsTests.swift */; }; 61D00339E0A84FBA44A0AF62 /* FlagViewTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E79C1C23CB5619203CAD28A3 /* FlagViewTrackingTests.swift */; }; + 63D62559B4584A3C778A232C /* MockContentfulTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26C4493D66695EEA5637A015 /* MockContentfulTransport.swift */; }; 68EBA430F06D7974D323C432 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F03F8B039B4ED4A7E7A32D5 /* SceneDelegate.swift */; }; 68FDF01398E5C12FD8A37277 /* AnalyticsEventDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC9CA7E33164E91046FF6DF /* AnalyticsEventDisplay.swift */; }; 6BEDE4E72E002CF5C3A8A9E1 /* ExtendedViewTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1324154A5A5CE58F32970813 /* ExtendedViewTrackingTests.swift */; }; @@ -51,6 +55,7 @@ 9F3986444C6D6D3BCB8ECEA3 /* RichText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820BE574DFF5E022220890CD /* RichText.swift */; }; A375768DD5B98ECEC51B04C9 /* FlagViewTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E79C1C23CB5619203CAD28A3 /* FlagViewTrackingTests.swift */; }; B1A5A931BBDFC3438E935012 /* OfflineBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682E3BF881B8093C2D5EF2CE /* OfflineBehaviorTests.swift */; }; + B4EF5067C86627BF93949985 /* MockContentfulClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC271C5DD5F3CFBBFF9A24A /* MockContentfulClient.swift */; }; C04EF55982CA837E62CC0669 /* ContentfulFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F383552F49942336F9725020 /* ContentfulFetcher.swift */; }; C23B6242DA30D283D76C1B3E /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DDE184BA14D36BAC6A5936 /* Config.swift */; }; C4757AF18FE122AC807B27E7 /* NestedButtonTapTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B353BE52F9AEFA1D6213FB4 /* NestedButtonTapTestScreen.swift */; }; @@ -61,6 +66,7 @@ E3C31DA281B4A6D1DF55CE29 /* TapTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8DE746138D269D770FA86FA /* TapTrackingTests.swift */; }; ECBF1C0DC16E4532F4EEE49A /* ExtendedViewTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1324154A5A5CE58F32970813 /* ExtendedViewTrackingTests.swift */; }; EFEFDCD73D2FE1854045F5DB /* PreviewPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCEDAABD36DADC093A8FD78 /* PreviewPanelTests.swift */; }; + F00EF0DEE25152C62840DF82 /* MockContentfulClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC271C5DD5F3CFBBFF9A24A /* MockContentfulClient.swift */; }; F510376F8D6D3EFE7BB197E1 /* NestedContentEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354A498C8D7BC1CB974D2454 /* NestedContentEntryView.swift */; }; FE0F74AA3E04EB42454F14D1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A38EFCB367C35AF0AEB0A4AE /* TestHelpers.swift */; }; /* End PBXBuildFile section */ @@ -90,6 +96,7 @@ 102D5A209AF6AC8B69E4BD24 /* AnalyticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTests.swift; sourceTree = ""; }; 1324154A5A5CE58F32970813 /* ExtendedViewTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedViewTrackingTests.swift; sourceTree = ""; }; 1B353BE52F9AEFA1D6213FB4 /* NestedButtonTapTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NestedButtonTapTestScreen.swift; sourceTree = ""; }; + 26C4493D66695EEA5637A015 /* MockContentfulTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockContentfulTransport.swift; sourceTree = ""; }; 29032ACE1D4AEB3E5D1BB51A /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; 2E855C4FA2D4C37DB188398F /* OptimizationAppSwiftUI.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = OptimizationAppSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; 354A498C8D7BC1CB974D2454 /* NestedContentEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NestedContentEntryView.swift; sourceTree = ""; }; @@ -99,6 +106,7 @@ 5F03F8B039B4ED4A7E7A32D5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 682E3BF881B8093C2D5EF2CE /* OfflineBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBehaviorTests.swift; sourceTree = ""; }; 6B60B2B8362A57886F415EE4 /* LiveUpdatesTestViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveUpdatesTestViewController.swift; sourceTree = ""; }; + 6FC271C5DD5F3CFBBFF9A24A /* MockContentfulClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockContentfulClient.swift; sourceTree = ""; }; 72BC55BDAE08B0B386CCFA65 /* ContentfulOptimization */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ContentfulOptimization; path = ../../packages/ios/ContentfulOptimization; sourceTree = SOURCE_ROOT; }; 7A41CEBEA796C5A3179D691E /* LiveUpdatesTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveUpdatesTestScreen.swift; sourceTree = ""; }; 7FDD088B0ACEC1271B3C5509 /* OptimizedEntryUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizedEntryUIView.swift; sourceTree = ""; }; @@ -132,6 +140,7 @@ buildActionMask = 2147483647; files = ( 42933080441B12904731C661 /* ContentfulOptimization in Frameworks */, + 1FC8A89C845F30ACAD88586B /* Contentful in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -140,6 +149,7 @@ buildActionMask = 2147483647; files = ( 53F94866D0BA3E09271D73F7 /* ContentfulOptimization in Frameworks */, + 58EF6936C43184E5E5BB149B /* Contentful in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -213,6 +223,8 @@ D5DDE184BA14D36BAC6A5936 /* Config.swift */, F383552F49942336F9725020 /* ContentfulFetcher.swift */, 89CE81FC20185EE7C2FF7BEF /* EventStore.swift */, + 6FC271C5DD5F3CFBBFF9A24A /* MockContentfulClient.swift */, + 26C4493D66695EEA5637A015 /* MockContentfulTransport.swift */, A72A25C095AB9AA5B35E3F57 /* MockPreviewContentfulClient.swift */, 820BE574DFF5E022220890CD /* RichText.swift */, ); @@ -306,6 +318,7 @@ name = OptimizationAppSwiftUI; packageProductDependencies = ( B622890DF93C3F71C32F81AB /* ContentfulOptimization */, + 223614F3D65269BC06E8BC2A /* Contentful */, ); productName = OptimizationAppSwiftUI; productReference = 2E855C4FA2D4C37DB188398F /* OptimizationAppSwiftUI.app */; @@ -343,6 +356,7 @@ name = OptimizationAppUIKit; packageProductDependencies = ( 244F9A9729F24EEBFDC1862F /* ContentfulOptimization */, + E897BA8CDB1DDBF1C164A4E4 /* Contentful */, ); productName = OptimizationAppUIKit; productReference = BB9EE54CEA502AA570FA0154 /* OptimizationAppUIKit.app */; @@ -393,6 +407,7 @@ mainGroup = 603D5CDC672E61359F8B4927; minimizedProjectReferenceProxies = 1; packageReferences = ( + D519C5C4AF908F3FD0450A60 /* XCRemoteSwiftPackageReference "contentful.swift" */, 435335CDD6AD4D4978CBCABF /* XCLocalSwiftPackageReference "../../packages/ios/ContentfulOptimization" */, ); preferredProjectObjectVersion = 77; @@ -441,6 +456,8 @@ 4447121F6CD440EAC27713E7 /* EventStore.swift in Sources */, 93DDB4FCE61B74F35663301D /* LiveUpdatesTestScreen.swift in Sources */, 8BE046C5FD5539712A40551C /* MainScreen.swift in Sources */, + B4EF5067C86627BF93949985 /* MockContentfulClient.swift in Sources */, + 63D62559B4584A3C778A232C /* MockContentfulTransport.swift in Sources */, 7E0AE3A92E2EB13F275145C5 /* MockPreviewContentfulClient.swift in Sources */, 71EE02D789A2D2D922B4DC0A /* NavigationTestScreen.swift in Sources */, C4757AF18FE122AC807B27E7 /* NestedButtonTapTestScreen.swift in Sources */, @@ -481,6 +498,8 @@ 73A7EECB26977730B7241077 /* EventStore.swift in Sources */, D28A6D01EBA9877DD9174BCB /* LiveUpdatesTestViewController.swift in Sources */, 91A5DFA622284D666F20D46E /* MainViewController.swift in Sources */, + F00EF0DEE25152C62840DF82 /* MockContentfulClient.swift in Sources */, + 55EBF3F91D016BC49C2C077E /* MockContentfulTransport.swift in Sources */, 3EC1F3129468B71C9DE3C486 /* MockPreviewContentfulClient.swift in Sources */, 7CE0E6C067281C01D8F0A85F /* NavigationTestViewController.swift in Sources */, 9550770C73B171616E3E23C4 /* NestedButtonTapTestViewController.swift in Sources */, @@ -821,7 +840,23 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + D519C5C4AF908F3FD0450A60 /* XCRemoteSwiftPackageReference "contentful.swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/contentful/contentful.swift"; + requirement = { + kind = exactVersion; + version = 5.5.15; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 223614F3D65269BC06E8BC2A /* Contentful */ = { + isa = XCSwiftPackageProductDependency; + package = D519C5C4AF908F3FD0450A60 /* XCRemoteSwiftPackageReference "contentful.swift" */; + productName = Contentful; + }; 244F9A9729F24EEBFDC1862F /* ContentfulOptimization */ = { isa = XCSwiftPackageProductDependency; productName = ContentfulOptimization; @@ -830,6 +865,11 @@ isa = XCSwiftPackageProductDependency; productName = ContentfulOptimization; }; + E897BA8CDB1DDBF1C164A4E4 /* Contentful */ = { + isa = XCSwiftPackageProductDependency; + package = D519C5C4AF908F3FD0450A60 /* XCRemoteSwiftPackageReference "contentful.swift" */; + productName = Contentful; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 59C62CFF73857D71C2743362 /* Project object */; diff --git a/implementations/ios-sdk/README.md b/implementations/ios-sdk/README.md index f986ec0a5..c225966b2 100644 --- a/implementations/ios-sdk/README.md +++ b/implementations/ios-sdk/README.md @@ -40,13 +40,32 @@ Both apps share `shared/` for configuration, Contentful fetching, and analytics run the same UI test source tree from `uitests/` against their respective host apps so SDK behavior can be compared across UI frameworks. +## Contentful entry fetching + +Entries are fetched with Contentful's official Swift SDK, +[`contentful.swift`](https://github.com/contentful/contentful.swift), which is the integration path +recommended to customers. `shared/ContentfulFetcher.swift` queries by entry ID with `include(10)`, +and the fetched `Contentful.Entry` values are handed to the Optimization SDK's typed entry APIs: +`OptimizedEntry(entry:)` in SwiftUI and `resolveOptimizedEntry(baseline:)` in UIKit. The SDK encodes +them through `CTEntry`, so the app does no CDA JSON parsing or link resolution of its own. + +Entry-ID lookup stays app-owned; the Optimization SDK resolves personalization against entries the +app supplies rather than fetching them itself. + +`shared/MockContentfulTransport.swift` exists only to reach this repo's mock server and is not part +of the recommended integration. The mock multiplexes the Contentful, Experience, and Insights APIs +onto one port and namespaces the CDA under `/contentful/`, which `Contentful.Client` cannot express, +and it serves no `/locales` route, which the SDK requires before its first entry fetch. A production +app builds `Contentful.Client(spaceId:accessToken:)` against `cdn.contentful.com` and needs none of +it. + ## CDA locale handling The app defines one locale in shared config, passes it to the native SDK as top-level `locale`, and -passes it directly to the shared raw CDA fetch helper. Entries passed to SDK resolution use the -standard single-locale CDA entry shape. Do not use all-locale CDA responses or `locale=*`, because -entry resolution expects direct single-locale fields such as `fields.nt_experiences` and -`fields.nt_variants`. See +passes it to CDA requests through `localizeResults(withLocaleCode:)`. Entries passed to SDK +resolution use the standard single-locale CDA entry shape. Do not use all-locale CDA responses or +`locale=*`, because entry resolution expects direct single-locale fields such as +`fields.nt_experiences` and `fields.nt_variants`. See [Locale handling in the Optimization SDK Suite](../../documentation/concepts/locale-handling-in-the-optimization-sdk-suite.md) for the broader locale model and [Entry personalization and variant resolution](../../documentation/concepts/entry-personalization-and-variant-resolution.md#single-locale-cda-entry-contract) diff --git a/implementations/ios-sdk/project.yml b/implementations/ios-sdk/project.yml index b17b7dfa7..44baa3ef1 100644 --- a/implementations/ios-sdk/project.yml +++ b/implementations/ios-sdk/project.yml @@ -8,6 +8,13 @@ options: packages: ContentfulOptimization: path: ../../packages/ios/ContentfulOptimization + # The app fetches CDA entries with Contentful's official Swift SDK, the same + # way a customer app would. Pinned to the exact version + # packages/ios/ContentfulOptimization already resolves, so SPM has a single + # version to pick for both the app targets and the SDK package. + contentful.swift: + url: https://github.com/contentful/contentful.swift + exactVersion: 5.5.15 settings: base: @@ -30,6 +37,8 @@ targets: INFOPLIST_FILE: swiftui/Info.plist dependencies: - package: ContentfulOptimization + - package: contentful.swift + product: Contentful scheme: testTargets: [OptimizationAppUITestsSwiftUI] # Scheme pre-actions fire before any target (including Swift Package @@ -76,6 +85,8 @@ targets: INFOPLIST_FILE: uikit/Info.plist dependencies: - package: ContentfulOptimization + - package: contentful.swift + product: Contentful scheme: testTargets: [OptimizationAppUITestsUIKit] preActions: diff --git a/implementations/ios-sdk/shared/Config.swift b/implementations/ios-sdk/shared/Config.swift index 1d9091fac..0fd72d9ef 100644 --- a/implementations/ios-sdk/shared/Config.swift +++ b/implementations/ios-sdk/shared/Config.swift @@ -15,9 +15,23 @@ struct AppConfig { static let experienceBaseUrl = "http://localhost:8000/experience/" static let insightsBaseUrl = "http://localhost:8000/insights/" - static let contentfulBaseUrl = "http://localhost:8000/contentful/" + /// Host (with port) for the mock Contentful CDA, in the `host[:port]` form + /// `Contentful.Client` expects. A production app omits this entirely and + /// gets the default `cdn.contentful.com`. + static let contentfulHost = "localhost:8000" + + /// Path prefix the mock server namespaces the CDA under, so one process can + /// also serve the Experience and Insights APIs. Consumed only by + /// `MockContentfulTransport` — `Contentful.Client` builds paths from the + /// host root and has nowhere to put a prefix. + static let contentfulMockPathPrefix = "/contentful" + static let contentfulSpaceId = "mock-space-id" + /// The mock CDA ignores authorization, but `Contentful.Client` requires a + /// token to build its `Authorization` header. + static let contentfulAccessToken = "mock-access-token" + static let entryIds = [ "1MwiFl4z7gkwqGYdvCmr8c", "4ib0hsHWoSOnCVdDkizE8d", diff --git a/implementations/ios-sdk/shared/ContentfulFetcher.swift b/implementations/ios-sdk/shared/ContentfulFetcher.swift index 68fc7d999..eab5bb8dc 100644 --- a/implementations/ios-sdk/shared/ContentfulFetcher.swift +++ b/implementations/ios-sdk/shared/ContentfulFetcher.swift @@ -1,9 +1,21 @@ +import Contentful import Foundation +/// Fetches the home screen's content entries from the Contentful Delivery API. +/// +/// Entry-ID lookup is app-owned: the Optimization SDK resolves personalization +/// against entries the app supplies, it does not fetch them. The returned +/// `Contentful.Entry` values go straight into the SDK's typed entry APIs +/// (`OptimizedEntry(entry:)`, `resolveOptimizedEntry(baseline:)`), which encode +/// them through `CTEntry`. struct ContentfulFetcher { - static func fetchEntries(ids: [String], locale: String) async -> [[String: Any]] { - var entries: [[String: Any]] = [] + /// Matches the `include=10` CDA contract: linked entries are resolved by + /// `contentful.swift` up to ten levels deep. + private static let includeDepth: UInt = 10 + + static func fetchEntries(ids: [String], locale: String) async -> [Contentful.Entry] { + var entries: [Contentful.Entry] = [] for id in ids { if let entry = await fetchEntry(id: id, locale: locale) { entries.append(entry) @@ -12,73 +24,16 @@ struct ContentfulFetcher { return entries } - static func fetchEntry(id: String, locale: String) async -> [String: Any]? { - let queryLocale = locale.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - ?? AppConfig.defaultContentfulLocale - let urlString = "\(AppConfig.contentfulBaseUrl)spaces/\(AppConfig.contentfulSpaceId)/environments/\(AppConfig.environment)/entries?sys.id=\(id)&include=10&locale=\(queryLocale)" - guard let url = URL(string: urlString) else { return nil } - - do { - let (data, _) = try await URLSession.shared.data(from: url) - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let items = json["items"] as? [[String: Any]], - let entry = items.first - else { return nil } - - let includes = json["includes"] as? [String: Any] - return resolveLinks(in: entry, includes: includes) - } catch { - return nil - } - } - - // MARK: - Link Resolution - - private static func resolveLinks(in entry: [String: Any], includes: [String: Any]?) -> [String: Any] { - var lookup: [String: [String: Any]] = [:] - - if let includeEntries = includes?["Entry"] as? [[String: Any]] { - for e in includeEntries { - if let sys = e["sys"] as? [String: Any], let id = sys["id"] as? String { - lookup[id] = e - } - } - } - - if let includeAssets = includes?["Asset"] as? [[String: Any]] { - for a in includeAssets { - if let sys = a["sys"] as? [String: Any], let id = sys["id"] as? String { - lookup[id] = a - } - } - } - - return resolveValue(entry, lookup: lookup) as? [String: Any] ?? entry - } - - // `depth` counts logical link hops, not JSON-tree nodes, so a budget of 10 - // matches the `include=10` CDA contract regardless of how deeply the followed - // entries nest plain dictionaries and arrays. - private static func resolveValue(_ value: Any, lookup: [String: [String: Any]], depth: Int = 0) -> Any { - if let dict = value as? [String: Any] { - if let sys = dict["sys"] as? [String: Any], - let type = sys["type"] as? String, - type == "Link", - let id = sys["id"] as? String, - let resolved = lookup[id] { - guard depth < 10 else { return value } - return resolveValue(resolved, lookup: lookup, depth: depth + 1) - } - - var result: [String: Any] = [:] - for (key, val) in dict { - result[key] = resolveValue(val, lookup: lookup, depth: depth) - } - return result - } else if let array = value as? [Any] { - return array.map { resolveValue($0, lookup: lookup, depth: depth) } - } - - return value + static func fetchEntry(id: String, locale: String) async -> Contentful.Entry? { + // Single-locale request. Entry resolution expects direct fields such as + // `fields.nt_experiences`, so all-locale responses must not be used. + let query = Query.where(sys: .id, .equals(id)) + .include(includeDepth) + .localizeResults(withLocaleCode: locale) + + // A failed fetch renders as the loading state, which is what the + // offline-behavior suite drives. + let response = try? await MockContentfulClient.fetchEntries(matching: query) + return response?.items.first } } diff --git a/implementations/ios-sdk/shared/MockContentfulClient.swift b/implementations/ios-sdk/shared/MockContentfulClient.swift new file mode 100644 index 000000000..07bb104b3 --- /dev/null +++ b/implementations/ios-sdk/shared/MockContentfulClient.swift @@ -0,0 +1,39 @@ +import Contentful +import Foundation + +/// The demo app's Contentful Delivery API client, shared by every CDA read +/// (home-screen entries and the preview panel's audience/experience fetch). +/// +/// A production app constructs the client the same way, minus `host`, +/// `clientConfiguration`, and `sessionConfiguration` — those three exist only to +/// reach the local mock server. See ``MockContentfulTransport``. +enum MockContentfulClient { + + static let shared: Contentful.Client = { + var clientConfiguration = ClientConfiguration.default + // The mock server is plain HTTP; both Info.plists allow arbitrary loads. + clientConfiguration.secure = false + + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.protocolClasses = [MockContentfulTransport.self] + + return Contentful.Client( + spaceId: AppConfig.contentfulSpaceId, + environmentId: AppConfig.environment, + accessToken: AppConfig.contentfulAccessToken, + host: AppConfig.contentfulHost, + clientConfiguration: clientConfiguration, + sessionConfiguration: sessionConfiguration + ) + }() + + /// `contentful.swift` 5.5.15 exposes only completion-handler fetches, so the + /// app's `async` call sites bridge through a continuation. + static func fetchEntries(matching query: Query) async throws -> HomogeneousArrayResponse { + try await withCheckedThrowingContinuation { continuation in + shared.fetchArray(of: Contentful.Entry.self, matching: query) { result in + continuation.resume(with: result) + } + } + } +} diff --git a/implementations/ios-sdk/shared/MockContentfulTransport.swift b/implementations/ios-sdk/shared/MockContentfulTransport.swift new file mode 100644 index 000000000..bdac1227f --- /dev/null +++ b/implementations/ios-sdk/shared/MockContentfulTransport.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Adapts `Contentful.Client`'s request URLs to this repo's mock CDA server. +/// +/// A production app needs none of this. It builds +/// `Contentful.Client(spaceId:accessToken:)` against `cdn.contentful.com`, and +/// every URL the SDK generates is already correct. Two properties of the *mock* +/// server make the shim necessary here: +/// +/// - `lib/mocks` multiplexes the Contentful, Experience, and Insights APIs onto +/// one port, namespacing the CDA under `/contentful/`. `Contentful.Client` +/// builds `/spaces/...` from the host root and accepts only a `host[:port]`, +/// so there is nowhere to put the prefix. +/// - The SDK walks locale fallback chains itself, so it fetches `/locales` +/// before the first `/entries` call and refuses to decode entries without the +/// resulting localization context. The mock serves no `/locales` route. +/// +/// Installed on the demo client's session only (see ``MockContentfulClient``), +/// never registered globally, so no other networking in the app is affected. +final class MockContentfulTransport: URLProtocol { + + /// Mirrors `QueryConstants.maxLimit`, the limit the SDK sends for `/locales`. + private static let localesLimit = 1000 + + /// Carries the mock server's own locale set, matching the single default + /// locale in `lib/mocks/src/contentful/data/space/ctfl-space-data.json`. + /// `sys` is omitted deliberately: `Contentful.Locale` synthesizes it from + /// `code` when absent, and `LocalizationContext` only needs exactly one + /// locale flagged `default`. + private static let localesPayload: [String: Any] = [ + "sys": ["type": "Array"], + "total": 1, + "skip": 0, + "limit": localesLimit, + "items": [ + [ + "name": "English (United States)", + "code": AppConfig.defaultContentfulLocale, + "fallbackCode": NSNull(), + "default": true, + "contentDeliveryApi": true, + "contentManagementApi": true, + "optional": false, + ], + ], + ] + + /// Does not carry this protocol class, which is what keeps forwarded + /// requests from re-entering `startLoading()`. + private static let forwardingSession = URLSession(configuration: .ephemeral) + + private var forwardedTask: URLSessionDataTask? + + override class func canInit(with request: URLRequest) -> Bool { + guard let url = request.url, let host = url.host else { return false } + let hostWithPort = url.port.map { "\(host):\($0)" } ?? host + return hostWithPort == AppConfig.contentfulHost && url.path.hasPrefix("/spaces/") + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + if url.lastPathComponent == "locales" { + serveLocales(for: url) + } else { + forwardToMockServer(url) + } + } + + override func stopLoading() { + forwardedTask?.cancel() + forwardedTask = nil + } + + // MARK: - Locales + + private func serveLocales(for url: URL) { + guard let client, + let data = try? JSONSerialization.data(withJSONObject: Self.localesPayload), + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + ) + else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse)) + return + } + + client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client.urlProtocol(self, didLoad: data) + client.urlProtocolDidFinishLoading(self) + } + + // MARK: - Prefix rewrite + + private func forwardToMockServer(_ url: URL) { + guard let prefixed = Self.prefixed(url) else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + var forwarded = request + forwarded.url = prefixed + + forwardedTask = Self.forwardingSession.dataTask(with: forwarded) { [weak self] data, response, error in + guard let self, let client = self.client else { return } + + if let error { + client.urlProtocol(self, didFailWithError: error) + return + } + if let response { + client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + } + if let data { + client.urlProtocol(self, didLoad: data) + } + client.urlProtocolDidFinishLoading(self) + } + forwardedTask?.resume() + } + + private static func prefixed(_ url: URL) -> URL? { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + components.path = AppConfig.contentfulMockPathPrefix + components.path + return components.url + } +} diff --git a/implementations/ios-sdk/shared/MockPreviewContentfulClient.swift b/implementations/ios-sdk/shared/MockPreviewContentfulClient.swift index 6499c4da0..95554293a 100644 --- a/implementations/ios-sdk/shared/MockPreviewContentfulClient.swift +++ b/implementations/ios-sdk/shared/MockPreviewContentfulClient.swift @@ -1,73 +1,36 @@ +import Contentful import ContentfulOptimization import Foundation -/// `PreviewContentfulClient` that targets `AppConfig.contentfulBaseUrl` -/// (the local mock server) rather than Contentful's production CDA. +/// `PreviewContentfulClient` backed by `contentful.swift`, targeting the local +/// mock server rather than Contentful's production CDA. /// -/// Mirrors `ContentfulHTTPPreviewClient` in shape but uses the demo app's -/// configured base URL so the preview panel can load audiences and -/// experiences against the mock fixture during E2E tests. +/// Wrapping an existing Contentful SDK client is the integration the protocol +/// documents, as opposed to the built-in `ContentfulHTTPPreviewClient`. The +/// protocol is dictionary-shaped, so fetched entries are encoded back down with +/// `CTEntry.toDictionary()`. final class MockPreviewContentfulClient: PreviewContentfulClient { - private let baseUrl: String - private let spaceId: String - private let environment: String - private let accessToken: String - private let session: URLSession - - init( - baseUrl: String = AppConfig.contentfulBaseUrl, - spaceId: String = AppConfig.contentfulSpaceId, - environment: String = AppConfig.environment, - accessToken: String = "mock-access-token", - session: URLSession = .shared - ) { - self.baseUrl = baseUrl - self.spaceId = spaceId - self.environment = environment - self.accessToken = accessToken - self.session = session - } func getEntries(contentType: String, include: Int, skip: Int, limit: Int) async throws -> ContentfulEntriesResult { - let urlString = "\(baseUrl)spaces/\(spaceId)/environments/\(environment)/entries" - guard var components = URLComponents(string: urlString) else { - throw URLError(.badURL) - } - components.queryItems = [ - URLQueryItem(name: "content_type", value: contentType), - URLQueryItem(name: "include", value: String(include)), - URLQueryItem(name: "skip", value: String(skip)), - URLQueryItem(name: "limit", value: String(limit)), - ] - guard let url = components.url else { - throw URLError(.badURL) - } - - var request = URLRequest(url: url) - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) - else { - throw URLError(.badServerResponse) - } - - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw URLError(.cannotParseResponse) - } + let query = Query.where(contentTypeId: contentType) + .include(UInt(include)) + .skip(theFirst: UInt(skip)) + .limit(to: UInt(limit)) - let includesJSON = json["includes"] as? [String: Any] - let includedEntries = includesJSON?["Entry"] as? [[String: Any]] ?? [] + let response = try await MockContentfulClient.fetchEntries(matching: query) + // `contentful.swift` resolves links in place, so `items` carry expanded + // linked entries where the raw CDA response carried link stubs. The + // preview mappers read linked entries by `sys.id`, which both shapes + // provide, and `includes.Entry` is still populated for lookups. return ContentfulEntriesResult( - items: json["items"] as? [[String: Any]] ?? [], - total: json["total"] as? Int ?? 0, - skip: json["skip"] as? Int ?? 0, - limit: json["limit"] as? Int ?? 0, - includes: ContentfulIncludes(entries: includedEntries) + items: response.items.map { CTEntry($0).toDictionary() }, + total: Int(response.total), + skip: Int(response.skip), + limit: Int(response.limit), + includes: ContentfulIncludes( + entries: (response.includedEntries ?? []).map { CTEntry($0).toDictionary() } + ) ) } } diff --git a/implementations/ios-sdk/shared/RichText.swift b/implementations/ios-sdk/shared/RichText.swift index c6fb510b5..517674ac6 100644 --- a/implementations/ios-sdk/shared/RichText.swift +++ b/implementations/ios-sdk/shared/RichText.swift @@ -16,6 +16,19 @@ enum RichText { return dict["nodeType"] as? String == "document" && dict["content"] is [Any] } + /// Resolve a named field on a resolved entry to a display string. + /// + /// A Rich Text field arrives as a nested dictionary and a Symbol/Text field + /// as a plain string, so both shapes are read explicitly. `getField` is + /// called with a concrete type on purpose: with `T` inferred as `Any` it + /// always succeeds, so a missing field would come back non-nil. + static func resolveText(_ entry: CTEntry, field: String, client: OptimizationClient) -> String { + if let document: [String: Any] = entry.getField(field) { + return resolveText(document, client: client) + } + return resolveText(entry[field: field], client: client) + } + /// Resolve an entry's `text` field to a display string: flatten a Rich Text /// document (resolving merge tags), pass a plain string through, otherwise /// fall back to `"No content"`. @@ -56,8 +69,9 @@ enum RichText { let sys = target["sys"] as? [String: Any] else { return "[Merge Tag]" } - // A still-unresolved Link means `ContentfulFetcher` did not inline the - // entry; the flattener has nothing to resolve against. + // A still-unresolved Link means the CDA response never carried the + // linked entry, or `CTEntry` stubbed it as a back-edge; either way the + // flattener has nothing to resolve against. if sys["type"] as? String == "Link" { return "[Merge Tag]" } let contentTypeId = ((sys["contentType"] as? [String: Any])?["sys"] as? [String: Any])?["id"] as? String diff --git a/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift b/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift index afc66172d..db381c911 100644 --- a/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift +++ b/implementations/ios-sdk/swiftui/Components/ContentEntryView.swift @@ -1,34 +1,32 @@ +import Contentful import ContentfulOptimization import SwiftUI struct ContentEntryView: View { - let entry: [String: Any] + let entry: Contentful.Entry @EnvironmentObject private var client: OptimizationClient - private var entryId: String { - let sys = entry["sys"] as? [String: Any] - return sys?["id"] as? String ?? "" - } - var body: some View { + // The `Contentful.Entry` initializer encodes the entry into the shape + // the resolver expects once, at construction, and hands the resolved + // variant back as a `CTEntry`. OptimizedEntry( entry: entry, - accessibilityIdentifier: "content-entry-\(entryId)" + accessibilityIdentifier: "content-entry-\(entry.sys.id)" ) { resolvedEntry in - EntryContent(entry: resolvedEntry, entryId: entryId, client: client) + EntryContent(entry: resolvedEntry, entryId: entry.sys.id, client: client) } } } private struct EntryContent: View { - let entry: [String: Any] + let entry: CTEntry let entryId: String let client: OptimizationClient private var text: String { - let fields = entry["fields"] as? [String: Any] - return RichText.resolveText(fields?["text"], client: client) + RichText.resolveText(entry, field: "text", client: client) } var body: some View { diff --git a/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift b/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift index 8a66b4b08..e33055795 100644 --- a/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift +++ b/implementations/ios-sdk/swiftui/Components/NestedContentEntryView.swift @@ -1,22 +1,45 @@ +import Contentful import ContentfulOptimization import SwiftUI struct NestedContentEntryView: View { - let entry: [String: Any] + /// A nested tree's root comes from the CDA as a `Contentful.Entry`, while its + /// children arrive already expanded inside the resolved parent, as + /// dictionaries. Both go through `OptimizedEntry` — each through the + /// initializer matching its shape. + private enum Source { + case fetched(Contentful.Entry) + case expanded([String: Any]) + } + + private let source: Source @EnvironmentObject private var client: OptimizationClient - private var entryId: String { - let sys = entry["sys"] as? [String: Any] - return sys?["id"] as? String ?? "" + init(entry: Contentful.Entry) { + source = .fetched(entry) + } + + init(expandedEntry: [String: Any]) { + source = .expanded(expandedEntry) } var body: some View { - OptimizedEntry( - entry: entry, - accessibilityIdentifier: "content-entry-\(entryId)" - ) { resolvedEntry in - NestedContentItemView(resolvedEntry: resolvedEntry, client: client) + switch source { + case let .fetched(entry): + OptimizedEntry( + entry: entry, + accessibilityIdentifier: "content-entry-\(entry.sys.id)" + ) { resolvedEntry in + NestedContentItemView(resolvedEntry: resolvedEntry.toDictionary(), client: client) + } + case let .expanded(entry): + OptimizedEntry( + entry: entry, + accessibilityIdentifier: "content-entry-\(entryId(of: entry))" + ) { resolvedEntry in + NestedContentItemView(resolvedEntry: resolvedEntry, client: client) + } } } } @@ -42,7 +65,7 @@ private struct NestedContentItemView: View { NestedEntryText(entry: resolvedEntry, client: client) ForEach(0.. String { + let sys = entry["sys"] as? [String: Any] + return sys?["id"] as? String ?? "" +} diff --git a/implementations/ios-sdk/swiftui/Screens/LiveUpdatesTestScreen.swift b/implementations/ios-sdk/swiftui/Screens/LiveUpdatesTestScreen.swift index e37642996..73daa6a8a 100644 --- a/implementations/ios-sdk/swiftui/Screens/LiveUpdatesTestScreen.swift +++ b/implementations/ios-sdk/swiftui/Screens/LiveUpdatesTestScreen.swift @@ -1,10 +1,11 @@ +import Contentful import ContentfulOptimization import SwiftUI struct LiveUpdatesTestScreen: View { let onClose: () -> Void @EnvironmentObject private var client: OptimizationClient - @State private var entry: [String: Any]? + @State private var entry: Contentful.Entry? @State private var isLoading = true @State private var isIdentified = false @State private var globalLiveUpdates = false @@ -108,7 +109,7 @@ struct LiveUpdatesTestScreen: View { } @ViewBuilder - private func contentSections(entry: [String: Any]) -> some View { + private func contentSections(entry: Contentful.Entry) -> some View { OptimizationScrollView(accessibilityIdentifier: "live-updates-scroll-view") { VStack(spacing: 20) { VStack(alignment: .leading) { @@ -173,17 +174,15 @@ struct LiveUpdatesTestScreen: View { // MARK: - LiveUpdatesEntryDisplay private struct LiveUpdatesEntryDisplay: View { - let entry: [String: Any] + let entry: CTEntry let prefix: String private var text: String { - let fields = entry["fields"] as? [String: Any] - return fields?["text"] as? String ?? "No content" + entry[field: "text"] ?? "No content" } private var entryId: String { - let sys = entry["sys"] as? [String: Any] - return sys?["id"] as? String ?? "" + entry.id ?? "" } var body: some View { diff --git a/implementations/ios-sdk/swiftui/Screens/MainScreen.swift b/implementations/ios-sdk/swiftui/Screens/MainScreen.swift index ec39dae64..ec4de574e 100644 --- a/implementations/ios-sdk/swiftui/Screens/MainScreen.swift +++ b/implementations/ios-sdk/swiftui/Screens/MainScreen.swift @@ -1,10 +1,11 @@ import Combine +import Contentful import ContentfulOptimization import SwiftUI struct MainScreen: View { @EnvironmentObject var client: OptimizationClient - @State private var entries: [[String: Any]] = [] + @State private var entries: [Contentful.Entry] = [] @State private var showNavigationTest = false @State private var showLiveUpdatesTest = false @State private var showNestedButtonTapTest = false @@ -120,13 +121,8 @@ struct MainScreen: View { } } - private func isNestedContent(_ entry: [String: Any]) -> Bool { - guard let sys = entry["sys"] as? [String: Any], - let contentType = sys["contentType"] as? [String: Any], - let innerSys = contentType["sys"] as? [String: Any], - let id = innerSys["id"] as? String - else { return false } - return id == "nestedContent" + private func isNestedContent(_ entry: Contentful.Entry) -> Bool { + entry.sys.contentTypeId == "nestedContent" } private func handleIdentify() { diff --git a/implementations/ios-sdk/uikit/Components/ContentEntryUIView.swift b/implementations/ios-sdk/uikit/Components/ContentEntryUIView.swift index 8fed9eb82..1cdab8b34 100644 --- a/implementations/ios-sdk/uikit/Components/ContentEntryUIView.swift +++ b/implementations/ios-sdk/uikit/Components/ContentEntryUIView.swift @@ -1,13 +1,14 @@ +import Contentful import ContentfulOptimization import UIKit final class ContentEntryUIView: UIView { - init(client: OptimizationClient, entry: [String: Any], scrollView: UIScrollView?) { + init(client: OptimizationClient, entry: Contentful.Entry, scrollView: UIScrollView?) { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false - let entryId = entryId(for: entry) + let entryId = entry.sys.id let optimized = OptimizedEntryUIView( client: client, entry: entry, @@ -75,8 +76,3 @@ private final class EntryContentView: UIView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } } - -private func entryId(for entry: [String: Any]) -> String { - let sys = entry["sys"] as? [String: Any] - return (sys?["id"] as? String) ?? "" -} diff --git a/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift b/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift index 5622d4d02..f6b396319 100644 --- a/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift +++ b/implementations/ios-sdk/uikit/Components/NestedContentEntryUIView.swift @@ -1,22 +1,53 @@ +import Contentful import ContentfulOptimization import UIKit final class NestedContentEntryUIView: UIView { - init(client: OptimizationClient, entry: [String: Any], scrollView: UIScrollView?) { + /// A nested tree's root comes from the CDA as a `Contentful.Entry`, while its + /// children arrive already expanded inside the resolved parent, as + /// dictionaries. + private enum Source { + case fetched(Contentful.Entry) + case expanded([String: Any]) + } + + convenience init(client: OptimizationClient, entry: Contentful.Entry, scrollView: UIScrollView?) { + self.init(client: client, source: .fetched(entry), scrollView: scrollView) + } + + convenience init(client: OptimizationClient, expandedEntry: [String: Any], scrollView: UIScrollView?) { + self.init(client: client, source: .expanded(expandedEntry), scrollView: scrollView) + } + + private init(client: OptimizationClient, source: Source, scrollView: UIScrollView?) { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false - let entryId = (entry["sys"] as? [String: Any])?["id"] as? String ?? "" - - let optimized = OptimizedEntryUIView( - client: client, - entry: entry, - scrollView: scrollView, - accessibilityIdentifier: "content-entry-\(entryId)" - ) { resolved in + let contentBuilder: (_ resolved: [String: Any]) -> UIView = { resolved in NestedContentItemUIView(client: client, resolvedEntry: resolved, scrollView: scrollView) } + + let optimized: OptimizedEntryUIView + switch source { + case let .fetched(entry): + optimized = OptimizedEntryUIView( + client: client, + entry: entry, + scrollView: scrollView, + accessibilityIdentifier: "content-entry-\(entry.sys.id)", + contentBuilder: contentBuilder + ) + case let .expanded(entry): + let entryId = (entry["sys"] as? [String: Any])?["id"] as? String ?? "" + optimized = OptimizedEntryUIView( + client: client, + entry: entry, + scrollView: scrollView, + accessibilityIdentifier: "content-entry-\(entryId)", + contentBuilder: contentBuilder + ) + } optimized.translatesAutoresizingMaskIntoConstraints = false addSubview(optimized) NSLayoutConstraint.activate([ @@ -56,7 +87,9 @@ private final class NestedContentItemUIView: UIView { stack.addArrangedSubview(NestedEntryText(entry: resolvedEntry, client: client)) for child in nestedEntries(in: resolvedEntry) { - stack.addArrangedSubview(NestedContentEntryUIView(client: client, entry: child, scrollView: scrollView)) + stack.addArrangedSubview( + NestedContentEntryUIView(client: client, expandedEntry: child, scrollView: scrollView) + ) } } diff --git a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift index fd56531d3..7bdf731b0 100644 --- a/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift +++ b/implementations/ios-sdk/uikit/Components/OptimizedEntryUIView.swift @@ -1,11 +1,26 @@ import Combine +import Contentful import ContentfulOptimization import UIKit final class OptimizedEntryUIView: UIView { + /// A baseline entry either straight from the CDA — which the SDK can resolve + /// through its typed API — or already expanded inside a resolved parent + /// entry, which only exists in dictionary form. + private enum Baseline { + case fetched(Contentful.Entry) + case expanded([String: Any]) + } + private let client: OptimizationClient - private let entry: [String: Any] + private let baseline: Baseline + + /// Dictionary form of the baseline, encoded once at init. `ViewTrackingController` + /// and `TrackingMetadata` are dictionary-only, and it doubles as the + /// unresolved value and the resolve fallback. + private let baselineDictionary: [String: Any] + private let liveUpdates: Bool? private let globalLiveUpdates: Bool private let trackTaps: Bool @@ -24,7 +39,36 @@ final class OptimizedEntryUIView: UIView { private var contentOffsetObservation: NSKeyValueObservation? private var boundsObservation: NSKeyValueObservation? - init( + /// Resolves through `resolveOptimizedEntry(baseline: Contentful.Entry, ...)`, + /// the typed SDK entry point. + convenience init( + client: OptimizationClient, + entry: Contentful.Entry, + scrollView: UIScrollView?, + liveUpdates: Bool? = nil, + globalLiveUpdates: Bool = false, + trackTaps: Bool = true, + trackViews: Bool = true, + accessibilityIdentifier: String? = nil, + contentBuilder: @escaping (_ resolved: [String: Any]) -> UIView + ) { + self.init( + client: client, + baseline: .fetched(entry), + baselineDictionary: CTEntry(entry).toDictionary(), + scrollView: scrollView, + liveUpdates: liveUpdates, + globalLiveUpdates: globalLiveUpdates, + trackTaps: trackTaps, + trackViews: trackViews, + accessibilityIdentifier: accessibilityIdentifier, + contentBuilder: contentBuilder + ) + } + + /// For entries that only exist in dictionary form: children expanded inside a + /// resolved parent, and locally constructed test entries. + convenience init( client: OptimizationClient, entry: [String: Any], scrollView: UIScrollView?, @@ -34,16 +78,43 @@ final class OptimizedEntryUIView: UIView { trackViews: Bool = true, accessibilityIdentifier: String? = nil, contentBuilder: @escaping (_ resolved: [String: Any]) -> UIView + ) { + self.init( + client: client, + baseline: .expanded(entry), + baselineDictionary: entry, + scrollView: scrollView, + liveUpdates: liveUpdates, + globalLiveUpdates: globalLiveUpdates, + trackTaps: trackTaps, + trackViews: trackViews, + accessibilityIdentifier: accessibilityIdentifier, + contentBuilder: contentBuilder + ) + } + + private init( + client: OptimizationClient, + baseline: Baseline, + baselineDictionary: [String: Any], + scrollView: UIScrollView?, + liveUpdates: Bool?, + globalLiveUpdates: Bool, + trackTaps: Bool, + trackViews: Bool, + accessibilityIdentifier: String?, + contentBuilder: @escaping (_ resolved: [String: Any]) -> UIView ) { self.client = client - self.entry = entry + self.baseline = baseline + self.baselineDictionary = baselineDictionary self.scrollView = scrollView self.liveUpdates = liveUpdates self.globalLiveUpdates = globalLiveUpdates self.trackTaps = trackTaps self.trackViews = trackViews self.contentBuilder = contentBuilder - self.resolvedEntry = entry + self.resolvedEntry = baselineDictionary super.init(frame: .zero) self.accessibilityIdentifier = accessibilityIdentifier self.isAccessibilityElement = false @@ -85,7 +156,7 @@ final class OptimizedEntryUIView: UIView { // MARK: - Personalization private var isPersonalized: Bool { - guard let fields = entry["fields"] as? [String: Any] else { return false } + guard let fields = baselineDictionary["fields"] as? [String: Any] else { return false } return fields["nt_experiences"] != nil } @@ -103,21 +174,31 @@ final class OptimizedEntryUIView: UIView { } private func resolve() -> Bool { - if isPersonalized { - let result = client.resolveOptimizedEntry( - baseline: entry, - selectedOptimizations: effectiveOptimizations - ) - resolvedEntry = result.entry.toDictionary(fallback: entry) - resolvedOptimization = result.selectedOptimization - resolvedOptimizationContextId = result.optimizationContextId - return result.isEmptyVariant - } else { - resolvedEntry = entry + guard isPersonalized else { + resolvedEntry = baselineDictionary resolvedOptimization = nil resolvedOptimizationContextId = nil return false } + + let result: ResolvedOptimizedEntry + switch baseline { + case let .fetched(entry): + result = client.resolveOptimizedEntry( + baseline: entry, + selectedOptimizations: effectiveOptimizations + ) + case let .expanded(entry): + result = client.resolveOptimizedEntry( + baseline: entry, + selectedOptimizations: effectiveOptimizations + ) + } + + resolvedEntry = result.entry.toDictionary(fallback: baselineDictionary) + resolvedOptimization = result.selectedOptimization + resolvedOptimizationContextId = result.optimizationContextId + return result.isEmptyVariant } private func subscribeToPersonalizations() { @@ -181,7 +262,7 @@ final class OptimizedEntryUIView: UIView { trackingController?.onDisappear() trackingController = ViewTrackingController( client: client, - entry: entry, + entry: baselineDictionary, optimizationContextId: resolvedOptimizationContextId, selectedOptimization: resolvedOptimization ) @@ -252,7 +333,7 @@ final class OptimizedEntryUIView: UIView { @objc private func handleTap() { let metadata = TrackingMetadata( - entry: entry, + entry: baselineDictionary, optimizationContextId: resolvedOptimizationContextId, selectedOptimization: resolvedOptimization ) diff --git a/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift b/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift index 978014281..26fba00b4 100644 --- a/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift +++ b/implementations/ios-sdk/uikit/Screens/LiveUpdatesTestViewController.swift @@ -1,4 +1,5 @@ import Combine +import Contentful import ContentfulOptimization import UIKit @@ -6,7 +7,7 @@ final class LiveUpdatesTestViewController: UIViewController { private let client: OptimizationClient private let personalizedEntryId = "2Z2WLOx07InSewC3LUB3eX" - private var entry: [String: Any]? + private var entry: Contentful.Entry? private var isIdentified = false private var globalLiveUpdates = false private var isPreviewPanelSimulated = false @@ -235,7 +236,7 @@ final class LiveUpdatesTestViewController: UIViewController { } private func makeSection( - entry: [String: Any], + entry: Contentful.Entry, title: String, subtitle: String, liveUpdates: Bool?, diff --git a/implementations/ios-sdk/uikit/Screens/MainViewController.swift b/implementations/ios-sdk/uikit/Screens/MainViewController.swift index 42f8c25ca..325d69254 100644 --- a/implementations/ios-sdk/uikit/Screens/MainViewController.swift +++ b/implementations/ios-sdk/uikit/Screens/MainViewController.swift @@ -1,11 +1,12 @@ import Combine +import Contentful import ContentfulOptimization import UIKit final class MainViewController: UIViewController { private let client: OptimizationClient - private var entries: [[String: Any]] = [] + private var entries: [Contentful.Entry] = [] private var firstAppearHandled = false private var cancellables = Set() @@ -242,13 +243,8 @@ final class MainViewController: UIViewController { // MARK: - Helpers - private func isNestedContent(_ entry: [String: Any]) -> Bool { - guard let sys = entry["sys"] as? [String: Any], - let contentType = sys["contentType"] as? [String: Any], - let innerSys = contentType["sys"] as? [String: Any], - let id = innerSys["id"] as? String - else { return false } - return id == "nestedContent" + private func isNestedContent(_ entry: Contentful.Entry) -> Bool { + entry.sys.contentTypeId == "nestedContent" } } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift index 308ecf71f..1e74df588 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Contentful/CTEntry.swift @@ -30,14 +30,30 @@ public struct CTEntry { self.entry = entry } - /// The `init(any:fallback:)` default — every reader below treats an empty entry as "absent." - static let empty = CTEntry(CDA.Entry(sys: nil, fields: [:], metadata: nil)) + /// An entry with no `sys` and no fields — every reader treats it as "absent." The default + /// `fallback` for the fail-soft initializers. + public static let empty = CTEntry(CDA.Entry(sys: nil, fields: [:], metadata: nil)) public init(_ contentfulEntry: Contentful.Entry) { entry = CDA.Entry(contentfulEntry, ancestors: []) } - init(json: String) throws { + /// Wraps a raw CDA entry dictionary — the shape ``toDictionary()`` produces and the shape the + /// dictionary-based SDK APIs accept — so callers holding an entry as a dictionary can read it + /// through `getField`/`hasField` instead of `as?` casts. + /// + /// Fail-soft, matching `CTEntry.from(any:fallback:)` on Android: a dictionary containing values + /// with no JSON representation logs and yields `fallback` (an empty entry by default) rather + /// than throwing. + public init(dictionary: [String: Any], fallback: @autoclosure () -> CTEntry = .empty) { + self.init(any: dictionary, fallback: fallback()) + } + + /// Parses a raw CDA entry JSON object, as produced by ``toJSON()``. + /// + /// Throws rather than falling back — unlike ``init(dictionary:fallback:)`` — because a malformed + /// JSON string carries a decoding error worth surfacing. + public init(json: String) throws { guard let data = json.data(using: .utf8) else { throw OptimizationError.configError("JSON string is not valid UTF-8") } diff --git a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift index 385b57aa0..760363b38 100644 --- a/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift +++ b/packages/ios/ContentfulOptimization/Sources/ContentfulOptimization/Tracking/TapTrackingModifier.swift @@ -21,12 +21,21 @@ private struct TapObserver: UIViewRepresentable { } } -/// Attaches its recognizer to its *superview*, not itself: SwiftUI's -/// `.background()` makes this a sibling of `content`, and a sibling's -/// recognizer never sees touches that hit-test into `content`. +/// Attaches its recognizer to the *window*, then scopes each tap back to its own +/// frame. +/// +/// A nearer ancestor is not usable. SwiftUI draws `Text` and friends into a +/// shared display list rather than one `UIView` per view, and `.background()` +/// puts this observer in a container of its own — so the container SwiftUI +/// happens to place it in is not reliably an ancestor of the region the user +/// taps, and a recognizer there never sees the touch. The window always is an +/// ancestor. `cancelsTouchesInView`/`delaysTouchesEnded` are both off, so +/// observing from up there still never competes for touches a nested +/// interactive child needs. private final class TapObserverView: UIView, UIGestureRecognizerDelegate { var onTap: (() -> Void)? private weak var attachedView: UIView? + private var recognizer: UITapGestureRecognizer? override init(frame: CGRect) { super.init(frame: frame) @@ -36,17 +45,49 @@ private final class TapObserverView: UIView, UIGestureRecognizerDelegate { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } - override func didMoveToSuperview() { - super.didMoveToSuperview() - guard let superview, attachedView !== superview else { return } - attachedView = superview + deinit { + detach() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + guard let window else { + detach() + return + } + attach(to: window) + } + + private func attach(to view: UIView) { + guard attachedView !== view else { return } + detach() + let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesBegan = false + recognizer.delaysTouchesEnded = false recognizer.delegate = self - superview.addGestureRecognizer(recognizer) + view.addGestureRecognizer(recognizer) + + self.recognizer = recognizer + attachedView = view + } + + private func detach() { + if let recognizer, let attachedView { + attachedView.removeGestureRecognizer(recognizer) + } + recognizer = nil + attachedView = nil } - @objc private func handleTap() { + /// The window-level recognizer sees every tap in the app, so a tap counts for + /// this entry only when it lands inside this observer's frame — which + /// `.background()` sizes to the tracked content. Matches the UIKit path, + /// where the entry's own recognizer also fires for taps on nested controls. + @objc private func handleTap(_ recognizer: UITapGestureRecognizer) { + guard window != nil, !bounds.isEmpty else { return } + guard bounds.contains(recognizer.location(in: self)) else { return } onTap?() } diff --git a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift index 82cf29d4f..109079320 100644 --- a/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift +++ b/packages/ios/ContentfulOptimization/Tests/ContentfulOptimizationTests/CTEntryTests.swift @@ -1340,4 +1340,50 @@ final class CTEntryTests: XCTestCase { XCTAssertEqual(resolved.id, "fallback-id") } + + // MARK: - The public construction surface + + /// `init(dictionary:fallback:)` and `init(json:)` are what a consumer holding a raw CDA entry + /// (an expanded child entry, a locally built one) uses to read it through `getField`/`hasField` + /// rather than `as?` casts. They mirror `CTEntry.from(any:)`/`from(json:)` on Android. + func testInitDictionaryReadsSysAndFields() { + let resolved = CTEntry(dictionary: [ + "sys": [ + "id": "e1", + "contentType": ["sys": ["id": "content", "type": "Link", "linkType": "ContentType"]], + ], + "fields": ["title": "Hello"], + ]) + + XCTAssertEqual(resolved.id, "e1") + XCTAssertEqual(resolved.contentTypeId, "content") + XCTAssertEqual(resolved[field: "title"], "Hello") + } + + func testInitDictionaryFallsBackForUnsupportedType() { + let resolved = CTEntry(dictionary: ["fields": ["publishedAt": Date()]]) + + XCTAssertNil(resolved.id) + XCTAssertFalse(resolved.hasField("publishedAt")) + } + + func testInitDictionaryUsesProvidedFallback() { + let fallback = CTEntry(dictionary: ["sys": ["id": "fallback-id"], "fields": [:]]) + let resolved = CTEntry(dictionary: ["fields": ["publishedAt": Date()]], fallback: fallback) + + XCTAssertEqual(resolved.id, "fallback-id") + } + + func testInitJSONReadsSysAndFields() throws { + let resolved = try CTEntry(json: """ + {"sys":{"id":"e1"},"fields":{"title":"Hello"}} + """) + + XCTAssertEqual(resolved.id, "e1") + XCTAssertEqual(resolved[field: "title"], "Hello") + } + + func testInitJSONThrowsForMalformedJSON() { + XCTAssertThrowsError(try CTEntry(json: "not json")) + } }