iOS SDK
ReflectSDK for native iOS — a Swift facade over the shared ReflectCore engine, consumable from Swift and Objective-C. This is the native SDK for a UIKit or SwiftUI app; if you ship through Flutter, React Native or Unity, use that wrapper instead.
ReflectSDK 1.0.0 is written in Swift and is fully usable from a pure Objective-C app. That takes deliberate work, because most of a modern Swift API is invisible to Objective-C: structs, enums with associated values, generics, default arguments and async/await simply do not cross the bridge.
So the SDK ships an @objc RFL* facade over NSObject-derived parameter classes — RFLReflect, RFLConfig,RFLDeepLinkData, RFLAttributionData, RFLLifecycle,RFLStandardEvents and friends — each with an explicit selector so nothing is mangled, and with a completion-handler twin of every async method. A pure Objective-C sample app (Examples/ObjCSample, which contains no Swift at all) plus a suite of interop tests keep it honest: they assert that every type reaches the Obj-C runtime under its RFL* name and only that name, and that every completion block fires exactly once, on the main queue.
Installation (CocoaPods)
Both pod lines below are required. Neither ReflectSDK nor the shared ReflectCore engine is published on CocoaPods trunk — both are consumed from their GitHub tags — and CocoaPods does not follow a git-sourced pod's dependency to another git source. Declaring only ReflectSDK fails with"None of your spec sources contain a spec satisfying the dependency: ReflectCore (~> 1.1)".
# Podfile
platform :ios, '13.0'
target 'YourApp' do
pod 'ReflectSDK', :git => 'https://github.com/bablu147/reflect-sdk-ios.git', :tag => '1.0.1'
pod 'ReflectCore', :git => 'https://github.com/bablu147/reflect-ios.git', :tag => '1.1.4'
endLANG=en_US.UTF-8 pod installpod needs a UTF-8 locale or it crashes inside unicode_normalize. Afterwards open the generated .xcworkspace, never the.xcodeproj.
Pin both tags exactly. ReflectCore 1.1.4 fixes first-session delivery (install latched at durable persistence, retry ladder, activation + background flush); 1.1.3 fixes jailbreak detection false-positiving on all real iPhones; 1.1.2 adds the batch-size/byte clamps; 1.1.1 is the first core release that does not brand every event flutter on the wire; 1.1.0 carries the privacy engine but has that branding bug, and 1.0.0 predates the privacy engine this facade's gate assumes. Neither is a valid target.
Swift Package Manager is not supported
Package.swift in this SDK, and shipping one that cannot resolve would be worse than shipping none. The reason is upstream: ReflectCore imports UIKit and is distributed as a CocoaPod, and the core repository's ownPackage.swift is a macOS executable target for its privacy test suite — it deliberately excludes the production core sources, so there is no SPM library product for a facade manifest to depend on. Unblocking it means restructuring the core repository into an iOS library product exporting ReflectCore. Until then, CocoaPods is the only supported integration path.Requirements
- iOS 13.0+, Swift 5.0+, Xcode 15+
- Depends on the shared
ReflectCoreengine (~> 1.1, resolving to1.1.4) — no other third-party dependencies AdServicesandAppTrackingTransparencyare weak-linked;AdAttributionKitis deliberately not linked- Sends
sdk_version = ios-1.0.0on the wire, tohttps://api.reflect.cloudby default
Quick start
clickId and every query parameter from the buffered copy. Getting this order wrong permanently loses first-install click attribution — it cannot be recovered on a later launch.Swift — UIKit AppDelegate
import ReflectSDK
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 1. Listeners first.
Reflect.onDeepLink { link in
print("deep link:", link.url, link.path ?? "", link.source) // .cold / .warm / .deferred
}
Reflect.onAttributionChanged { attribution in
print("partner:", attribution.partner ?? "-")
}
// 2. Capture the cold-launch URL (buffered in memory, replayed after init).
Reflect.application(application, didFinishLaunchingWithOptions: launchOptions)
// 3. Initialize.
Reflect.initialize(ReflectConfig(
appKey: "YOUR_APP_KEY",
signingSecret: "YOUR_APP_HMAC_SECRET", // required for shared_hmac apps
debug: true
))
return true
}
}
// Anywhere afterwards:
Reflect.setUserId("user_123")
Reflect.trackEvent("level_complete", properties: ["level": 5, "score": 42_000])
Reflect.trackEvent(.purchase) // type-safe standard name
// async twins where a value comes back:
let attribution = await Reflect.getAttribution()
let link = await Reflect.getInitialDeepLink()Objective-C — same app, no Swift
@import ReflectSDK;
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// 1. Listeners first.
[RFLReflect onDeepLink:^(RFLDeepLinkData *link) {
NSLog(@"deep link: %@ %@", link.url, link.path ?: @"");
}];
[RFLReflect onAttributionChanged:^(RFLAttributionData *attribution) {
NSLog(@"partner: %@", attribution.partner ?: @"-");
}];
// 2. Capture the cold-launch URL.
[RFLLifecycle application:application didFinishLaunchingWithOptions:launchOptions];
// 3. Initialize.
RFLConfig *config = [[RFLConfig alloc] initWithAppKey:@"YOUR_APP_KEY"];
config.signingSecret = @"YOUR_APP_HMAC_SECRET";
config.debug = YES;
[RFLReflect initializeWithConfig:config];
return YES;
}
@end
// Anywhere afterwards:
[RFLReflect setUserId:@"user_123"];
[RFLReflect trackEvent:@"level_complete" properties:@{@"level": @5}];
// Completion-handler twins of the async methods:
[RFLReflect getAttributionWithCompletion:^(RFLAttributionData *a) { /* main queue */ }];
[RFLReflect getInitialDeepLinkWithCompletion:^(RFLDeepLinkData *link) { /* main queue */ }];New apps default to server policy shared_hmac: signingSecret is required, and unsigned traffic is rejected with signature_required. Inject the value through ignored local/CI configuration; never commit a real one.
App lifecycle wiring
The core installs no URL handling of its own — iOS delivers URLs toyour delegate and there is no supported way to intercept that. Everything below is one line per callback.
| Behaviour | Automatic | You must wire |
|---|---|---|
| Deferred deep link (post-install resolution) | Yes — core, on initialize | — |
| Listener delivery on the main queue | Yes | register before initialize |
| Last-link cache + late-subscriber replay | Yes | — |
| Sessions, batching, retry, signed ingest | Yes | — |
Cold launch (launchOptions) | No | Reflect.application(_:didFinishLaunchingWithOptions:) |
| Cold launch via a scene | No | Reflect.scene(willConnectWith:) |
| Warm custom-scheme URL | No | Reflect.handle(url:) / Reflect.scene(openURLContexts:) |
| Universal Link | No | Reflect.continue(userActivity:) |
(a) UIKit — AppDelegate only
func application(_ app: UIApplication, open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
Reflect.handle(url: url)
return false // observe only; don't claim exclusive handling
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
Reflect.continue(userActivity: userActivity)
return false
}Objective-C uses [RFLLifecycle handleURL:url] and [RFLLifecycle continueUserActivity:userActivity].
(b) UIKit — SceneDelegate
Keep the listeners and initialize in the AppDelegate; the scene supplies the link. With scenes, launchOptions is usually empty — callingReflect.application(_:didFinishLaunchingWithOptions:) anyway is harmless and keeps the non-scene path working.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
Reflect.scene(willConnectWith: connectionOptions) // covers urlContexts AND userActivities
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
Reflect.scene(openURLContexts: URLContexts)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
Reflect.continue(userActivity: userActivity)
}(c) SwiftUI
The SwiftUI App lifecycle has no delegate by default, so use the view modifiers:
@main
struct SampleApp: App {
init() {
Reflect.onDeepLink { DeepLinkRouter.shared.handle($0) }
Reflect.onAttributionChanged { AttributionStore.shared.update($0) }
Reflect.initialize(ReflectConfig(appKey: "YOUR_APP_KEY",
signingSecret: "YOUR_APP_HMAC_SECRET"))
}
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { Reflect.handle(url: $0) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) {
Reflect.continue(userActivity: $0)
}
}
}
}Info.plist keys
None of this can be automated for a native app — there is no build post-processor here.
| Key | Required when | Note |
|---|---|---|
NSUserTrackingUsageDescription | any ATT prompt | Without it, requesting tracking authorization crashes the app. |
NSPrivacyTrackingDomains (your app's PrivacyInfo.xcprivacy) | opt-in only | Declaring the ingest host blocks all SDK traffic until ATT is authorized — undecided/denying users deliver nothing. Default recommendation: leave it undeclared (iOS zeroes the IDFA without ATT anyway). See the transport gate below. |
SKAdNetworkItems | using SKAN | One SKAdNetworkIdentifier dict per ad network you buy from. |
NSAdvertisingAttributionReportEndpoint | SKAN with a custom endpoint | e.g. https://api.reflect.cloud |
CFBundleURLTypes | custom-scheme deep links | CFBundleURLSchemes = ["yourscheme"] |
com.apple.developer.associated-domains (entitlement) | Universal Links | applinks:links.yourdomain.com |
NSAppTransportSecurity | never | The SDK is HTTPS-only; leave arbitrary loads off. |
The SDK ships its own PrivacyInfo.xcprivacy inside ReflectSDK.bundle, but a bundle manifest cannot declare NSPrivacyTrackingDomains on your behalf — that one belongs to your app.
App Tracking Transparency
Reflect.requestTrackingAuthorization { status in
// .authorized / .denied / .restricted / .notDetermined / .gateClosed / .unavailable
}
let status = await Reflect.requestTrackingAuthorization() // async twin
// Raw wire string, for parity with the Flutter/RN surface:
Reflect.requestTrackingAuthorizationRaw { raw in /* "authorized" | "denied" | ... */ }[RFLReflect requestTrackingAuthorizationWithCompletion:^(RFLTrackingStatus status) {
NSLog(@"ATT: %ld", (long)status);
}];| Status | Meaning |
|---|---|
.authorized / .denied / .restricted / .notDetermined | The user's real answer, as reported by the system. |
.gateClosed (98) | The privacy gate was shut at call time, so no prompt was shown and the call never reached ATTrackingManager. Grant consent and call again. Also returned when a privacy transition lands while the prompt is up — the answer is withheld because the posture changed mid-flight. |
.unavailable (99) | This OS cannot present a prompt: iOS < 14, the framework is missing, or the call timed out / returned something unusable. It does not mean the gate was closed. |
An app that presents ATT before it collects Reflect consent gets.gateClosed and no prompt. That is the intended behaviour, and the distinct status exists so it is discoverable — a closed gate must never be reported as.unavailable, which would tell a developer on a modern device that their OS cannot do ATT.
Other details: the completion is always delivered on the main queue; the deadline is 600 s (a user can leave the app parked on the system prompt), after which you get .unavailable; ReflectConfig(autoRequestIosTracking: true)presents the prompt during initialize instead; and if you callATTrackingManager.requestTrackingAuthorization yourself, tell the SDK — a status change it never learns about leaves the first install batch parked until the next launch.
The tracking-domain transport gate. An app that declares its ingest host inNSPrivacyTrackingDomains — an opt-in posture; the default since 2026-08 is to leave the host undeclared — has every connection to that host refused by iOS until ATT is answered. It surfaces asNSURLErrorNotConnectedToInternet while the status is stillnot_determined. The core recognises exactly that pair, parks the drain against a 5-minute ceiling and persists no backoff deadline, so the queue is preserved and flushes once the prompt is answered. A denial does not reopen the domain. "No events until the second launch" is almost always this, not a bug.
SKAdNetwork
SKAN is armed automatically on first launch (unless autoRegisterSkan: false), and conversion values are driven automatically from trackRevenue,trackPurchase and trackSubscription using a bucket schema fetched from GET {baseUrl}/skan/cv-schema?app_key=… (24 h TTL, exactly 64 entries).Without a cached schema no conversion value is ever sent — the SDK never invents buckets. Non-increasing updates are suppressed.
Reflect.updateConversionValue(7, coarseValue: "medium", lockWindow: false) { result in
if result.success { print("sent via", result.method ?? "-") }
else { print("failed:", result.error ?? "-") }
}
// async twin — the completion parameter has NO default value, so that
// Reflect.updateConversionValue(7) inside an async function is unambiguous.
let result = await Reflect.updateConversionValue(7)[RFLReflect updateConversionValue:7 coarseValue:@"medium" lockWindow:NO
completion:^(RFLConversionValueResult *r) { /* ... */ }];A fineValue outside 0...63 fails client-side withfine_value_out_of_range before anything is dispatched. Other failure codes:measurement_disabled, privacy_state_changed,skan_not_available, collection_blocked (the privacy gate refused).
_skan_cv event) on device.Privacy & consent
The facade runs its own fail-closed gate in front of the core's — the same contract the native Android SDK implements. It exists for two reasons the core cannot cover: there is no server-side PII strip for the sticky email the facade injects, and the bridge is asynchronous while the public API is not — setConsent(false) must closesynchronously at the call boundary, because the app has already told the user they opted out.
Reflect.setConsent(false) { persisted in /* false => the denial did not durably persist */ }
Reflect.setEmail("[email protected]") // ignored while denied
Reflect.trackEvent("checkout") // dropped while denied
Reflect.setConsent(true) { _ in } // waits for native confirmation
Reflect.setEmail("[email protected]") // re-supply after the grantsetConsent,setEnabled,setAdvertisingConsentandsetThirdPartySharingeach take an optional completion carrying aBool. The core returns realprivacy_persistence_failederrors on exactly these four — without the completion you could never learn that a GDPR opt-out failed to persist.- Pass the raw email; the server hashes it. Never hash it yourself. It stays sticky until
clearEmail(), a consent denial,deleteUserData(), or replacement. setEnabled(false)suppresses reversibly; a consent denial ordeleteUserData()destroys irreversibly. Only the latter clears the facade's local email, global-property and deep-link caches.- Calls made before
initializeare folded into the init arguments, but only the restrictive ones: a pre-initsetConsent(false)/setEnabled(false)is carried forward, a pre-init grant is not. - During warm-up (
initializeissued, posture not yet read back) measurement calls wait in a bounded FIFO. If the readback fails the gate stays closed and the FIFO is discarded — an unknown posture is treated as a denial.
See Privacy & consent for the server-side model.
API surface
Every method below is available from Objective-C on RFLReflect under an explicitRFL* selector. Methods that return a value also have an async twin in Swift (getAttribution, getAttributionWithTimeout,getInitialDeepLink, getLastDeepLink, resolveDeepLink,getInstallUuid, isEnabled, deleteUserData,verifyPurchase, verifyAndTrackPurchase,requestTrackingAuthorization, updateConversionValue,getDebugState).
| Group | API |
|---|---|
| Lifecycle | Reflect.initialize(_ config:) · initialize(baseUrl:) · isInitialized · isDebugMode · sdkVersion ("ios-1.0.0") |
| Events & revenue | trackEvent(_:) · trackEvent(_:properties:) · trackEvent(_:options:) · trackEvent(_ name: ReflectEventName, properties:) · trackRevenue(_:) · trackPurchase(_:) · trackSubscription(_:) · trackAdRevenue(_:) · verifyPurchase(_:completion:) · verifyAndTrackPurchase(_:completion:) · flush() |
| Identity & properties | setUserId(_:) · clearUserId() · setEmail(_:) · clearEmail() · setUserProperties(_:) · setExternalDeviceId(_:) (nil clears) · userId · installUuid · getInstallUuid(completion:) · setGlobalProperty(_:value:) · unsetGlobalProperty(_:) · clearGlobalProperties() · setPartnerParameter(_:value:) · unsetPartnerParameter(_:) · clearPartnerParameters() · setPushToken(_:) · registerPushToken(_:provider:) · setAudience(_:) |
| Privacy | setConsent(_:completion:) · getConsent() · setAdvertisingConsent(_:completion:) · setThirdPartySharing(_:completion:) · setPartnerSharing(partner:key:value:) · setEnabled(_:completion:) · isEnabled(completion:) · setOfflineMode(_:) · setIntegrityToken(_:) · deleteUserData(completion:) |
| ATT & SKAdNetwork | requestTrackingAuthorization(completion:) · requestTrackingAuthorizationRaw(completion:) · updateConversionValue(_:coarseValue:lockWindow:completion:) |
| Deep links & attribution | onDeepLink(_:) · removeDeepLinkListener(_:) · lastDeepLink · getLastDeepLink(completion:) · getInitialDeepLink(completion:) · resolveDeepLink(_:completion:) · handleDeepLink(_:) · onAttributionChanged(_:) · removeAttributionListener(_:) · getAttribution(completion:) · getAttributionWithTimeout(timeoutMs:completion:) |
| Host lifecycle | Reflect.application(_:didFinishLaunchingWithOptions:) · Reflect.handle(url:) · Reflect.continue(userActivity:) · Reflect.scene(willConnectWith:) · Reflect.scene(openURLContexts:) (Obj-C: RFLLifecycle) |
| Standard events & mediation | ReflectStandardEvents (20 typed helpers over the canonical wire names) · ReflectMax · ReflectAdMob · ReflectLevelPlay ad-revenue mappers |
| Diagnostics | getDebugState(completion:) · ReflectDebugOverlay.attach(to:) / .detach() · ReflectEventValidator.validate(_:properties:) |
getAttributionWithTimeout takes milliseconds(timeoutMs: Int = 3000, clamped to 0...3_600_000) — named that way deliberately, because a developer carrying the Flutter/RN habit of passing 3000to a TimeInterval would otherwise get 3000 seconds.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| "None of your spec sources contain a spec satisfying the dependency: ReflectCore (~> 1.1)" | Only ReflectSDK was declared. Neither pod is on CocoaPods trunk, and CocoaPods will not follow a git-sourced pod's dependency to another git source — add the explicit ReflectCore :git/:tag line too. |
pod install crashes in unicode_normalize | Run it as LANG=en_US.UTF-8 pod install. |
ATT returns gateClosed and no prompt appears | The privacy gate is shut. Grant consent (setConsent(true)) and call again. |
requestTrackingAuthorization crashes the app | NSUserTrackingUsageDescription is missing from Info.plist. |
| No events until the second launch | The ATT / tracking-domain transport gate. Expected, not a bug. |
Deep link arrives with no clickId and no query params | A listener was registered after initialize. Register first. |
getInitialDeepLink() returns nil | Reflect.application(_:didFinishLaunchingWithOptions:) / Reflect.scene(willConnectWith:) was never called. |
| An event never arrives and nothing is logged | Client-side validation dropped it (name must match [a-z][a-z0-9_-]*, 1–64 chars, ≤25 properties). Turn on debug: true, or pre-flight with ReflectEventValidator.validate. |
| Events are dropped right after launch | The privacy gate is closed: consent denied, setEnabled(false), or a failed posture readback (which fails closed). Check Reflect.getConsent() and getDebugState. |
| No conversion value is ever sent | No cached SKAN schema — confirm the CV-schema endpoint returns 64 entries. The SDK never invents buckets. |
@import ReflectSDK; fails in Obj-C | DEFINES_MODULE was overridden, or you opened the .xcodeproj instead of the .xcworkspace. |
Obj-C app fails to link: Undefined symbols: __swift_FORCE_LOAD_$_swiftCompatibility56 | A pure Obj-C target compiles no Swift, so Xcode adds no Swift library search paths. The podspec fixes this via user_target_xcconfig; the error means that LIBRARY_SEARCH_PATHS entry was overwritten by a $(inherited)-less setting in your own xcconfig. |
| Reinstall is not counted as a new install | Expected — the install_uuid is preserved by design. |