Internal handoff · iOS
This document explains how the app starts: Splash → ATT → Adjust → OneSignal tags → In-App Message → Home. Follow it step by step. You do not need deep SDK experience.
| Area | Owner |
|---|---|
Splash screen UI, ATT prompt, Adjust SDK, fetching IDs, sending tags, waiting for OneSignal user, firing app_ready, navigating to Home |
You (app developer) |
OneSignal dashboard: In-App Message HTML, Full Screen layout, trigger app_ready = true, offer URL via WebView replacement (not iframe); LP close uses error:// |
Already done by the OneSignal owner — do not recreate or change it |
The offer is shown by OneSignal IAM (not a custom in-app browser screen).
| Term | Meaning |
|---|---|
| Splash | The first full-screen image while the app prepares. |
| ATT | App Tracking Transparency — Apple’s “Allow tracking?” popup (iOS 14+). |
| Adjust | Attribution SDK. Gives us adid, and helps with idfa / idfv / campaign. |
| OneSignal | Used here for In-App Messages only (not push notifications in this flow). |
| IAM | In-App Message — a full-screen web message shown by OneSignal over the app. |
| Tag | A key/value saved on the OneSignal user (example: idfa → device ID). |
| Trigger | A condition that tells OneSignal “show this IAM now”. We use app_ready = true. |
| onesignalId | OneSignal’s user id. We wait for it (with timeout) before relying on tags/IAM. |
Work in parallel where possible. Do not wait one-after-another if steps can overlap.
Initialize OneSignal immediately. Keep IAM paused so it does not show too early.
Start the launch coordinator. Show splash image.
(A) Wait for OneSignal user id (max ~12 seconds)
(B) Show ATT → then init Adjust → fetch adid, idfa, idfv in parallel
Also listen for Adjust attribution and send campaign when it arrives (do not block for it).
After Adjust IDs are ready and OneSignal user wait finished (or timed out), call addTags with mmp_id, idfa, idfv.
Only after tags: addTrigger("app_ready", "true") then unpause. Wait until IAM did display (or timeout ~20s). Do not insert a multi-second sleep after tags.
After IAM is shown (+ short hold) or timeout: switch to Home with animations disabled. IAM can stay on top.
max(ATT+Adjust IDs, OneSignal user) plus IAM display time — not the sum of every step one after another.
Add the OneSignal iOS SDK (5.x) and link these products to your app target:
| Product / framework | Required? | Why |
|---|---|---|
OneSignalFramework |
Yes | Core OneSignal SDK (user, tags, init). |
OneSignalInAppMessages |
Yes | In-App Messages (IAM). Without this, IAM will not show. |
OneSignalLocation |
No | Not needed for this flow. |
OneSignalExtension |
No | Notification Service Extension only — do not use for this IAM-only flow. |
Link AdjustSdk (not Adjust WebBridge). Also add these system frameworks to the app target (usually Optional / Weak):
| Framework | Required? | Why |
|---|---|---|
AdjustSdk (SPM) |
Yes | Attribution, adid, campaign, ID helpers. |
AdSupport.framework |
Yes | IDFA access. |
AppTrackingTransparency.framework |
Yes | ATT permission prompt (iOS 14+). |
AdServices.framework |
Yes (recommended) | Apple Search Ads / AdServices attribution support. |
StoreKit.framework |
Yes (recommended) | SKAdNetwork APIs. |
WebKit.framework |
Yes (recommended) | Used with Adjust / web surfaces; also needed for OneSignal IAM WebViews. |
AdjustWebBridge |
No | Not used in this flow — do not link it. |
In Xcode: select the app target → General → Frameworks, Libraries, and Embedded Content (and/or Package Products).
| Key | Why |
|---|---|
NSUserTrackingUsageDescription |
Required by Apple for the ATT popup text. |
OneSignal_in_app_message_hide_gray_overlay = true |
Cleaner full-screen IAM (less gray chrome). |
OneSignal_in_app_message_hide_drop_shadow = true |
Cleaner full-screen IAM (no drop shadow). |
NSAdvertisingAttributionReportEndpoint |
Send SKAdNetwork postback copies to Adjust: https://adjust-skadnetwork.com |
AttributionCopyEndpoint |
AdAttributionKit postback copies to Adjust: https://adjust-skadnetwork.com |
SKAdNetworkItems |
Network IDs for Meta / Google / TikTok / Snapchat — see next section. |
Add these IDs under SKAdNetworkItems in Info.plist so Apple can attribute installs
when you advertise on those networks (with Adjust). All values must be lowercase.
| Network | SKAdNetworkIdentifier |
|---|---|
| Meta (Facebook) | v9wttpbfk9.skadnetwork |
| Meta (Instagram) | n38lu8286q.skadnetwork |
cstr6suwn9.skadnetwork | |
| TikTok | 22mmun2rn5.skadnetwork |
| TikTok / Pangle | 238da6jt44.skadnetwork |
| TikTok / Pangle | gta9lk7p23.skadnetwork |
| Snapchat | 424m5254lk.skadnetwork |
| Snapchat | 8s468mfl3y.skadnetwork |
<key>SKAdNetworkItems</key>
<array>
<dict><key>SKAdNetworkIdentifier</key><string>v9wttpbfk9.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>n38lu8286q.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>cstr6suwn9.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>22mmun2rn5.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>238da6jt44.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>gta9lk7p23.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>424m5254lk.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>8s468mfl3y.skadnetwork</string></dict>
</array>
<key>NSAdvertisingAttributionReportEndpoint</key>
<string>https://adjust-skadnetwork.com</string>
<key>AttributionCopyEndpoint</key>
<string>https://adjust-skadnetwork.com</string>
Keep launch / IAM code separate from your product UI:
YourApp/
Launch/ ← Splash, ATT, Adjust, OneSignal, IAM gate
AppDelegate.swift
LaunchCoordinator.swift (SplashView + RootView)
OneSignalManager.swift
AdjustManager.swift
IAMErrorSchemeBridge.swift (error:// close + scroll)
WaitingTimeLog.swift (optional)
App/ ← Your real home / product screens
HomeView.swift (sample uses ContentView)
YourApp.swift (@main → RootView)
| File | Folder | Responsibility |
|---|---|---|
| AppDelegate.swift | Launch/ |
Initialize OneSignal as early as possible (IAM paused). |
| LaunchCoordinator.swift | Launch/ |
Orchestrates splash → ATT → Adjust → tags → IAM → Home. |
| OneSignalManager.swift | Launch/ |
Init, tags, wait for user id, trigger, IAM lifecycle. |
| AdjustManager.swift | Launch/ |
ATT, Adjust init, fetch IDs, campaign callback → tag. |
| IAMErrorSchemeBridge.swift | Launch/ |
Dismiss IAM on LP error://; re-enable WebView scroll for long offer pages. |
| WaitingTimeLog.swift (optional) | Launch/ |
Debug timing logs. Filter console by waitingTime. |
| HomeView.swift (or your main UI) | App/ |
Product home only — not part of the IAM launch flow. |
YourApp.swift (@main) |
root | Attach AppDelegate + show RootView. |
In RootView, replace HomeView() / ContentView() with your real home screen. Full samples: .
Use your real OneSignal App ID in code.
// Pseudo-code
OneSignal.initialize(appId, withLaunchOptions: launchOptions)
OneSignal.InAppMessages.paused = true // IMPORTANT: do not show yet
// add lifecycle listener (onDidDisplay)
// add user observer (onesignalId)
OneSignal.User.onesignalId with a timeout (example: 12 seconds). Continue even if empty after timeout.app_ready). Your app only initializes the SDK, sends tags, and fires the trigger — do not recreate dashboard content.
.active.ATTrackingManager.requestTrackingAuthorization().adid (retry a few times if empty)idfaidfvcampaign is available, send OneSignal tag campaign immediately. Do not wait for campaign before continuing launch..splash.HomeView).var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
destination = .main
}
After tags are written (addTags) — and only after the
onesignalId wait finished:
OneSignal.InAppMessages.addTrigger("app_ready", withValue: "true")OneSignal.InAppMessages.paused = falseonDidDisplay.addTags before the OneSignal user wait finishes.
Never fire app_ready before tags. Do not add a multi-second sleep after tags by default — correct order is enough.
If the offer URL contains literal {{ or %7B%7B, tags were missing at IAM render time: fix order/logging, do not lengthen splash with long delays.
The OneSignal dashboard message is already configured with trigger app_ready is true and Full Screen display. App code only fires the trigger.
Open Home when the IAM gate finishes:
onDidDisplay fired, then wait ~1 extra second, orAfter navigating Home, if IAM never displayed, you may keep retrying the trigger briefly in the background.
Do not dismiss the IAM when opening Home. Home loads underneath.
Tick every box before you call the integration done. Missing any item below usually breaks splash, tags, IAM, or Home.
OneSignalManager.initialize(launchOptions: launchOptions)
// inside initialize:
// OneSignal.initialize(...)
// OneSignal.InAppMessages.paused = true
// AFTER onesignalId wait + addTags(mmp_id, idfa, idfv):
OneSignal.InAppMessages.addTrigger("app_ready", withValue: "true")
OneSignal.InAppMessages.paused = false
app_ready + unpause → onDidDisplay / timeout → Home under IAM +
IAMErrorSchemeBridge (error:// + scroll) + frameworks + Info.plist.
This is the full prompt to integrate the launch flow in any iOS app. Open the agent on the target project, paste the block below, then fill the four placeholders.
DOC_URL_HERE — hosted URL of this docs/index.htmlPASTE_ONESIGNAL_APP_ID_HEREPASTE_ADJUST_APP_TOKEN_HEREEXISTING_HOME_VIEW_HERE — e.g. ContentView()Implement the iOS launch flow in THIS project using this documentation (read BOTH tabs — Guide and Code examples):
DOC_URL_HERE
## Credentials (use exactly — do not invent or leave placeholders)
- OneSignal App ID: PASTE_ONESIGNAL_APP_ID_HERE
- Adjust App Token: PASTE_ADJUST_APP_TOKEN_HERE
## Assumptions (do not question or rework)
- OneSignal dashboard In-App Message is ALREADY configured: Full Screen, trigger app_ready = "true".
- Do NOT create/edit OneSignal dashboard content, IAM HTML, or offer pages (HTML reference is for the dashboard owner).
- App-side integration only.
## Non-negotiable rules
1. Code examples tab is the source of truth. Paste those Swift samples as written — do not simplify, reorder steps, merge files creatively, or invent a different architecture.
2. Create folders Launch/ (splash, ATT, Adjust, OneSignal, IAM gate) and App/ (product UI only), as in the doc.
3. Replace YOUR_ONESIGNAL_APP_ID and YOUR_ADJUST_APP_TOKEN with the credentials above.
4. Do NOT request push notification permission. Do NOT add a Notification Service Extension.
5. Initialize OneSignal in AppDelegate with InAppMessages.paused = true BEFORE splash logic.
6. Exact order (do not reorder — wrong order breaks offer URL params):
a) AppDelegate: OneSignal init + InAppMessages.paused = true
b) Splash shows; in PARALLEL: wait for onesignalId (timeout OK) AND ATT → Adjust init → fetch adid/idfa/idfv
c) ONLY AFTER (b): OneSignal.User.addTags with mmp_id, idfa, idfv (skip empty). Log the tag values.
d) ONLY AFTER (c): addTrigger("app_ready", "true") then unpause IAM (same order as Code examples)
e) Wait IAM onDidDisplay (or timeout) → Home UNDER the IAM with animations disabled
Do NOT addTags before onesignalId wait finishes. Do NOT fire app_ready before tags.
Do NOT add a multi-second sleep after tags by default — correct order is enough. Optional ≤300ms settle only if logs show tags empty at trigger time.
7. If the offer / CloudFront URL contains literal "{{" or "%7B%7B", tags failed at IAM render — fix order/logging. Do not blame CocoaPods/SPM or lengthen splash with long delays.
8. Wire RootView case .main to this project's existing home screen: EXISTING_HOME_VIEW_HERE (e.g. ContentView() or MainTabView()).
9. Add/merge Info.plist keys from the doc (ATT usage text, OneSignal IAM chrome flags, Adjust SKAN endpoints, SKAdNetworkItems). Keep unrelated existing keys.
10. Link packages/frameworks from the doc: OneSignalFramework + OneSignalInAppMessages, AdjustSdk, AdSupport, AppTrackingTransparency, AdServices, StoreKit, WebKit. SPM is fine — CocoaPods is not required.
11. Ensure a splash image asset exists (name splash, or update code + Assets to match).
12. REQUIRED: Add Launch/IAMErrorSchemeBridge.swift from Code examples. Call IAMErrorSchemeBridge.install() at the start of OneSignalManager.initialize (before OneSignal.initialize). This dismisses the IAM when the offer LP navigates to error:// (close X).
13. REQUIRED: Re-enable scrolling on IAM WKWebViews (OneSignal sets scrollEnabled=false). Do this on IAM display and after webView didFinish (see Code examples / IAMErrorSchemeBridge). Long offer text must be scrollable.
14. When finished, walk section "Xcode checkpoint" in the Guide and reply with a checklist: done vs could-not-do (with reason).
## Done when
- Launch flow matches the doc end-to-end (onesignalId wait → tags → app_ready).
- error:// from the LP closes the IAM.
- Long offer pages scroll inside the IAM WebView.
- No leftover YOUR_* placeholders.
- Checkpoint items are checked or explicitly listed as blocked.
After copying: replace DOC_URL_HERE, both credentials, and EXISTING_HOME_VIEW_HERE, then send to the agent.
Use this when an app already has the launch flow, but LP close (error://)
does not dismiss the IAM and/or long offer text cannot scroll.
This project already has the Splash → ATT → Adjust → OneSignal tags → IAM → Home launch flow.
Do NOT re-implement the whole flow. Only apply these two fixes from the documentation (Code examples):
DOC_URL_HERE
## Fix 1 — LP close button (error://) must dismiss the IAM
1. Add Launch/IAMErrorSchemeBridge.swift exactly from the Code examples tab (step 8 — IAMErrorSchemeBridge).
2. At the start of OneSignalManager.initialize (before OneSignal.initialize), call:
IAMErrorSchemeBridge.install()
3. Do not add a transparent overlay X in HTML. Do not change the CloudFront LP.
4. The offer must load in the IAM WKWebView via OneSignal replacement (url_target replacement), NOT inside an iframe — otherwise error:// stays trapped in the iframe. If this app still uses an iframe IAM HTML, tell me; the dashboard owner must switch to the IAM HTML in the doc (Guide → OneSignal IAM HTML).
## Fix 2 — Long offer pages must scroll
1. OneSignal sets WKWebView.scrollView.isScrollEnabled = false on IAM webviews. Re-enable scrolling:
- When IAM displays (same place you already touch IAM chrome / onDidDisplay)
- And in IAMErrorSchemeBridge webView(_:didFinish:) after the LP loads
2. Set isScrollEnabled = true, bounces = true, alwaysBounceVertical = true.
## Do not
- Do not add multi-second sleeps after tags.
- Do not request push permission.
- Do not invent a different close URL scheme — LP uses error://.
## Done when
- Tapping LP X (closepage → error://) dismisses the IAM.
- Long LP content scrolls.
- Show a short diff summary of files changed.
Replace DOC_URL_HERE with this doc’s URL.
Paste this into the OneSignal In-App Message HTML editor (Full Screen, trigger app_ready = true).
It loads the offer with WebView replacement (no iframe), waits for
rendering_complete, then opens HTTPS CloudFront.
naming, mmp_id, idfv, package, gps_adid, click_idnaming ← OneSignal tag campaign; gps_adid ← tag idfaYOUR_BUNDLE_ID with the app’s bundle iderror:// — requires app IAMErrorSchemeBridgeFull paste-ready HTML is also under → IAM HTML.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>OneSignal In-App Message</title>
<style>
* {
-webkit-touch-callout: none;
-webkit-user-select: none;
box-sizing: border-box;
margin: 0;
padding: 0;
color-scheme: light;
}
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: #FFFFFF !important;
color-scheme: light;
overflow: hidden;
}
body {
position: fixed;
inset: 0;
background: #FFFFFF !important;
}
.liquid-seed {
display: none;
}
/* OneSignal measures .outer-content-container + #last-element for layout */
.outer-content-container {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
background: #FFFFFF !important;
}
#last-element {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 0;
}
</style>
</head>
<body>
<div id="liquid-seed" class="liquid-seed">{{ campaign }}|{{ mmp_id }}|{{ idfa }}|{{ idfv }}</div>
<div class="outer-content-container">
<div id="last-element"></div>
</div>
<script>
if (typeof iamInfo !== "undefined" && iamInfo) {
iamInfo.displayLocation = "full_screen";
iamInfo.location = "full_screen";
iamInfo.shouldVerticalDragDismissMessage = false;
}
function tagValue(tags, key) {
if (!tags || tags[key] == null) return "";
var value = String(tags[key]).trim();
// Unreplaced OneSignal Liquid must not leak into the LP URL
if (!value || value.indexOf("{{") !== -1) return "";
return value;
}
function tagsFromLiquidSeed() {
var el = document.getElementById("liquid-seed");
if (!el) return {};
var parts = (el.textContent || "").split("|");
return {
campaign: (parts[0] || "").trim(),
mmp_id: (parts[1] || "").trim(),
idfa: (parts[2] || "").trim(),
idfv: (parts[3] || "").trim()
};
}
function buildOfferUrl(tags) {
// Always include every key, even when the value is empty (e.g. naming=)
var params = new URLSearchParams();
params.set("naming", tagValue(tags, "campaign"));
params.set("mmp_id", tagValue(tags, "mmp_id"));
params.set("idfv", tagValue(tags, "idfv"));
params.set("package", "YOUR_BUNDLE_ID");
params.set("gps_adid", tagValue(tags, "idfa"));
params.set("click_id", tagValue(tags, "mmp_id"));
return "https://drt9lr4idn55x.cloudfront.net/lp/?" + params.toString();
}
function resolveTags() {
var tags = (typeof liquidPlayerTags !== "undefined" && liquidPlayerTags) ? liquidPlayerTags : {};
if (!tagValue(tags, "mmp_id") && !tagValue(tags, "idfa") && !tagValue(tags, "idfv") && !tagValue(tags, "campaign")) {
tags = tagsFromLiquidSeed();
}
return tags;
}
// Official OneSignal path: load URL inside the IAM WebView (no iframe).
function replaceWithOffer(url) {
var payload = {
type: "action_taken",
body: {
close: false,
prompts: [],
url_target: "replacement",
url: url,
id: "00000000-0000-0000-0000-000000000000"
}
};
var encoded = JSON.stringify(payload);
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iosListener) {
window.webkit.messageHandlers.iosListener.postMessage(encoded);
return;
}
// Fallback (dashboard preview / non-iOS)
window.location.replace(url);
}
function openOffer() {
if (typeof iamInfo !== "undefined" && iamInfo) {
iamInfo.displayLocation = "full_screen";
iamInfo.location = "full_screen";
iamInfo.shouldVerticalDragDismissMessage = false;
}
replaceWithOffer(buildOfferUrl(resolveTags()));
}
// CRITICAL: wait for window load so OneSignal can fire rendering_complete
// and display the IAM. Replacing on DOMContentLoaded hides the message forever.
window.addEventListener("load", function () {
setTimeout(openOffer, 50);
});
</script>
</body>
</html>
error://)If IAM does not appear but tags and app_ready logs are correct, escalate to the OneSignal/dashboard owner (not an app-code gap). If LP X does nothing and the dashboard still uses an iframe IAM, escalate to switch to the replacement HTML in section 18.
Paste in the order below. Keep launch code in Launch/ and your product UI in
App/. Credentials
(YOUR_ONESIGNAL_APP_ID and YOUR_ADJUST_APP_TOKEN
come from the human’s agent prompt — do not invent them. Use your own home screen name (sample:
HomeView).
app_ready = true. See Guide → AI agent prompt.
splash image asset → build. Click Copy on any block.
YourApp, HomeView, splash.
<key>NSUserTrackingUsageDescription</key>
<string>We use your device identifier to measure ad performance and deliver personalized offers.</string>
<key>OneSignal_in_app_message_hide_gray_overlay</key>
<true/>
<key>OneSignal_in_app_message_hide_drop_shadow</key>
<true/>
<key>NSAdvertisingAttributionReportEndpoint</key>
<string>https://adjust-skadnetwork.com</string>
<key>AttributionCopyEndpoint</key>
<string>https://adjust-skadnetwork.com</string>
<key>SKAdNetworkItems</key>
<array>
<!-- Meta -->
<dict><key>SKAdNetworkIdentifier</key><string>v9wttpbfk9.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>n38lu8286q.skadnetwork</string></dict>
<!-- Google -->
<dict><key>SKAdNetworkIdentifier</key><string>cstr6suwn9.skadnetwork</string></dict>
<!-- TikTok / Pangle -->
<dict><key>SKAdNetworkIdentifier</key><string>22mmun2rn5.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>238da6jt44.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>gta9lk7p23.skadnetwork</string></dict>
<!-- Snapchat -->
<dict><key>SKAdNetworkIdentifier</key><string>424m5254lk.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>8s468mfl3y.skadnetwork</string></dict>
</array>
Filter Xcode console with waitingTime. If you skip this file, delete every WaitingTimeLog. call in the other samples.
import Foundation
/// Splash/launch timing logs. Filter Xcode console by: `waitingTime`
enum WaitingTimeLog {
private static let lock = NSLock()
private static var t0: CFAbsoluteTime?
/// Starts once (AppDelegate). Later calls are no-ops.
static func startCounting() {
lock.lock()
defer { lock.unlock() }
guard t0 == nil else { return }
t0 = CFAbsoluteTimeGetCurrent()
print("[waitingTime] start counting")
}
static var elapsedMs: Int {
lock.lock()
defer { lock.unlock() }
guard let t0 else { return 0 }
return Int((CFAbsoluteTimeGetCurrent() - t0) * 1000.0)
}
static func mark(_ event: String) {
print("[waitingTime] \(event) \(elapsedMs)ms")
}
static func ready(_ event: String) {
print("[waitingTime] \(event): \(elapsedMs) milliseconds")
}
static func note(_ message: String) {
print("[waitingTime] \(message)")
}
}
YOUR_ONESIGNAL_APP_ID
Do not request push permission. IAM stays paused until tags are sent.
import Foundation
import UIKit
import OneSignalFramework
import WebKit
enum OneSignalManager {
static let appId = "YOUR_ONESIGNAL_APP_ID"
private static let readyTriggerKey = "app_ready"
private static let readyTriggerValue = "true"
private static var didInitialize = false
private static var didArmDisplay = false
private static let lifecycleBridge = IAMLifecycleBridge()
private static let userBridge = UserStateBridge()
private static var homeContinuation: CheckedContinuation<Void, Never>?
private static var userContinuation: CheckedContinuation<Void, Never>?
private static var didSignalHome = false
private static var iamDidDisplay = false
private static var didResolveUser = false
static var hasDisplayedIAM: Bool { iamDidDisplay }
static func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
guard !didInitialize else { return }
didInitialize = true
// Catch LP closepage() → error:// and dismiss the IAM WebView.
IAMErrorSchemeBridge.install()
#if DEBUG
OneSignal.Debug.setLogLevel(.LL_VERBOSE)
#endif
OneSignal.initialize(appId, withLaunchOptions: launchOptions)
OneSignal.InAppMessages.paused = true
OneSignal.InAppMessages.addLifecycleListener(lifecycleBridge)
OneSignal.User.addObserver(userBridge)
}
static func syncAdjustTags(adid: String, idfa: String, idfv: String) {
var tags: [String: String] = [
"mmp_id": adid,
"idfa": idfa,
"idfv": idfv,
]
tags = tags.filter { !$0.value.isEmpty }
guard !tags.isEmpty else { return }
OneSignal.User.addTags(tags)
}
/// Non-blocking; call whenever Adjust attribution arrives.
static func syncCampaignTag(_ campaign: String) {
let value = campaign.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
OneSignal.User.addTags(["campaign": value])
}
static func waitForOneSignalUser(timeoutSeconds: TimeInterval = 12) async {
WaitingTimeLog.mark("waiting for onesignal user id")
if let id = OneSignal.User.onesignalId, !id.isEmpty {
WaitingTimeLog.mark("one signal user id fetched")
return
}
didResolveUser = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
userContinuation = continuation
Task { @MainActor in
let steps = max(1, Int((timeoutSeconds / 0.25).rounded(.up)))
for _ in 0..<steps {
if let id = OneSignal.User.onesignalId, !id.isEmpty {
resolveUserReady()
return
}
try? await Task.sleep(nanoseconds: 250_000_000)
}
resolveUserReady()
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
resolveUserReady()
}
}
}
/// Splash stays up; IAM shows on top; then home loads underneath.
static func waitUntilIAMShownOnSplash(timeoutSeconds: TimeInterval = 20) async {
guard !didArmDisplay else { return }
didArmDisplay = true
didSignalHome = false
iamDidDisplay = false
armReadyTrigger()
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
homeContinuation = continuation
Task { @MainActor in
for _ in 0..<20 {
if didSignalHome { return }
OneSignal.InAppMessages.paused = false
OneSignal.InAppMessages.addTrigger(readyTriggerKey, withValue: readyTriggerValue)
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
#if DEBUG
if !iamDidDisplay {
print("[OneSignalManager] IAM not shown within \(timeoutSeconds)s — opening home anyway")
}
#endif
signalHomeReady()
}
}
}
static func continueTryingToShowIAMIfNeeded() {
guard !iamDidDisplay else { return }
Task { @MainActor in
for _ in 0..<15 {
if iamDidDisplay { return }
OneSignal.InAppMessages.paused = false
OneSignal.InAppMessages.addTrigger(readyTriggerKey, withValue: readyTriggerValue)
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
}
private static func armReadyTrigger() {
OneSignal.InAppMessages.addTrigger(readyTriggerKey, withValue: readyTriggerValue)
OneSignal.InAppMessages.paused = false
}
fileprivate static func handleIAMDidDisplay() {
guard !iamDidDisplay else { return }
iamDidDisplay = true
WaitingTimeLog.mark("IAM did display")
forceOpaqueWhiteIAMChrome()
Task { @MainActor in
for _ in 0..<6 {
forceOpaqueWhiteIAMChrome()
try? await Task.sleep(nanoseconds: 300_000_000)
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
signalHomeReady()
}
}
fileprivate static func handleIAMWillDisplay() {
forceOpaqueWhiteIAMChrome()
}
/// Transparent offer HTML can show home underneath — force solid white WebViews.
@MainActor
private static func forceOpaqueWhiteIAMChrome() {
let white = UIColor.white
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
paintOpaqueWhite(in: window, color: white)
enableScrollingOnIAMWebViews(in: window)
}
}
}
/// OneSignal sets `scrollView.scrollEnabled = false` on IAM WKWebViews — re-enable so long LP text can scroll.
@MainActor
private static func enableScrollingOnIAMWebViews(in root: UIView) {
if let webView = root as? WKWebView {
webView.scrollView.isScrollEnabled = true
webView.scrollView.bounces = true
webView.scrollView.alwaysBounceVertical = true
if #available(iOS 11.0, *) {
webView.scrollView.contentInsetAdjustmentBehavior = .never
}
}
for sub in root.subviews {
enableScrollingOnIAMWebViews(in: sub)
}
}
@MainActor
private static func paintOpaqueWhite(in view: UIView, color: UIColor) {
let name = String(describing: type(of: view))
if name.contains("WKWebView") {
view.isOpaque = true
view.backgroundColor = color
view.layer.backgroundColor = color.cgColor
for sub in view.subviews {
sub.isOpaque = true
sub.backgroundColor = color
}
}
if name.contains("OSInAppMessage") || name.contains("OneSignal") {
view.isOpaque = true
if view.backgroundColor == nil || view.backgroundColor?.cgColor.alpha == 0 {
view.backgroundColor = color
}
}
for sub in view.subviews {
paintOpaqueWhite(in: sub, color: color)
}
}
fileprivate static func signalHomeReady() {
guard !didSignalHome else { return }
didSignalHome = true
OneSignal.InAppMessages.paused = false
OneSignal.InAppMessages.addTrigger(readyTriggerKey, withValue: readyTriggerValue)
homeContinuation?.resume()
homeContinuation = nil
}
fileprivate static func resolveUserReady() {
guard !didResolveUser else { return }
didResolveUser = true
if let id = OneSignal.User.onesignalId, !id.isEmpty {
WaitingTimeLog.mark("one signal user id fetched")
} else {
WaitingTimeLog.mark("one signal user id timeout (empty)")
}
userContinuation?.resume()
userContinuation = nil
}
fileprivate static func handleUserStateChanged() {
if let id = OneSignal.User.onesignalId, !id.isEmpty {
resolveUserReady()
}
}
}
private final class IAMLifecycleBridge: NSObject, OSInAppMessageLifecycleListener {
func onWillDisplay(event: OSInAppMessageWillDisplayEvent) {
Task { @MainActor in
OneSignalManager.handleIAMWillDisplay()
}
}
func onDidDisplay(event: OSInAppMessageDidDisplayEvent) {
Task { @MainActor in
OneSignalManager.handleIAMDidDisplay()
}
}
}
private final class UserStateBridge: NSObject, OSUserStateObserver {
func onUserStateDidChange(state: OSUserChangedState) {
Task { @MainActor in
OneSignalManager.handleUserStateChanged()
}
}
}
YOUR_ADJUST_APP_TOKEN
ATT must finish before Adjust.initSdk. Campaign tagging is async and must not block launch.
import Foundation
import UIKit
import AdjustSdk
import AppTrackingTransparency
import AdSupport
final class AdjustManager: NSObject, AdjustDelegate {
static let shared = AdjustManager()
static let appToken = "YOUR_ADJUST_APP_TOKEN"
private var didInitialize = false
private var didLogCampaign = false
private override init() {
super.init()
}
func requestTrackingAuthorization() async {
guard #available(iOS 14, *) else { return }
await Self.waitUntilAppIsActive()
_ = await ATTrackingManager.requestTrackingAuthorization()
}
private static func waitUntilAppIsActive() async {
for _ in 0..<50 {
let isActive = await MainActor.run {
UIApplication.shared.applicationState == .active
}
if isActive { return }
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
func initializeSDK() {
guard !didInitialize else { return }
didInitialize = true
let config = ADJConfig(appToken: Self.appToken, environment: ADJEnvironmentProduction)
#if DEBUG
config?.logLevel = ADJLogLevel.verbose
#else
config?.logLevel = ADJLogLevel.suppress
#endif
config?.delegate = self
Adjust.initSdk(config)
// Cached attribution → campaign tag without waiting for a later callback.
Adjust.attribution(completionHandler: { [weak self] attribution in
self?.pushCampaignTag(from: attribution)
})
}
func attributionParameters() async -> (adid: String, idfa: String, idfv: String) {
async let idfa = Self.fetchIdfa()
async let idfv = Self.fetchIdfv()
async let adid = Self.fetchAdid(retries: 10)
return await (adid, idfa, idfv)
}
private static func fetchIdfa() async -> String {
let value: String = await withCheckedContinuation { continuation in
Adjust.idfa { value in
if let value, !value.isEmpty, value != "00000000-0000-0000-0000-000000000000" {
continuation.resume(returning: value)
} else {
continuation.resume(returning: ASIdentifierManager.shared().advertisingIdentifier.uuidString)
}
}
}
WaitingTimeLog.mark("idfa")
return value
}
private static func fetchIdfv() async -> String {
let value: String = await withCheckedContinuation { continuation in
Adjust.idfv { value in
if let value, !value.isEmpty {
continuation.resume(returning: value)
} else {
continuation.resume(returning: UIDevice.current.identifierForVendor?.uuidString ?? "")
}
}
}
WaitingTimeLog.mark("idfv")
return value
}
private static func fetchAdid(retries: Int) async -> String {
for attempt in 0..<retries {
let value: String = await withCheckedContinuation { continuation in
Adjust.adid { adid in
continuation.resume(returning: adid ?? "")
}
}
if !value.isEmpty {
WaitingTimeLog.mark("adid")
return value
}
if attempt + 1 < retries {
try? await Task.sleep(nanoseconds: 200_000_000)
}
}
WaitingTimeLog.mark("adid (empty)")
return ""
}
private func pushCampaignTag(from attribution: ADJAttribution?) {
guard let campaign = attribution?.campaign?
.trimmingCharacters(in: .whitespacesAndNewlines),
!campaign.isEmpty else { return }
if !didLogCampaign {
didLogCampaign = true
WaitingTimeLog.mark("campaign")
}
OneSignalManager.syncCampaignTag(campaign)
}
func adjustAttributionChanged(_ attribution: ADJAttribution?) {
pushCampaignTag(from: attribution)
}
// Required: without this, Adjust opens deferred links by default.
func adjustDeferredDeeplinkReceived(_ deeplink: URL?) -> Bool { false }
}
splash
In RootView, replace HomeView() with your real home screen from App/.
import SwiftUI
import Combine
// Splash → ATT → Adjust → OneSignal tags → IAM → Home
enum AppLaunchDestination: Equatable {
case splash
case main
}
@MainActor
final class LaunchCoordinator: ObservableObject {
@Published var destination: AppLaunchDestination = .splash
private let adjust = AdjustManager.shared
private var hasResolved = false
private var didStart = false
func start() {
guard !didStart else { return }
didStart = true
WaitingTimeLog.startCounting()
WaitingTimeLog.mark("splash launch")
Task { await runLaunchFlow() }
}
private func runLaunchFlow() async {
// Parallel: OneSignal user wait + ATT → Adjust → IDs
async let oneSignalUser: Void = OneSignalManager.waitForOneSignalUser(timeoutSeconds: 12)
async let adjustIds: (adid: String, idfa: String, idfv: String) = {
WaitingTimeLog.mark("start ATT")
await adjust.requestTrackingAuthorization()
WaitingTimeLog.mark("ATT answered")
WaitingTimeLog.mark("start adjust initialization")
adjust.initializeSDK()
WaitingTimeLog.ready("adjust initialization")
WaitingTimeLog.note("waiting for idfa adid idfv campaign")
return await adjust.attributionParameters()
}()
let ids = await adjustIds
await oneSignalUser
WaitingTimeLog.mark("adjust tags ready to sync")
OneSignalManager.syncAdjustTags(adid: ids.adid, idfa: ids.idfa, idfv: ids.idfv)
WaitingTimeLog.mark("start IAM wait")
await OneSignalManager.waitUntilIAMShownOnSplash(timeoutSeconds: 20)
WaitingTimeLog.mark("IAM gate finished")
guard !hasResolved else { return }
hasResolved = true
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
destination = .main
}
WaitingTimeLog.mark("navigated to home")
OneSignalManager.continueTryingToShowIAMIfNeeded()
}
}
struct SplashView: View {
var body: some View {
GeometryReader { geo in
Image("splash")
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
}
.ignoresSafeArea()
.background(Color.black)
}
}
struct RootView: View {
@StateObject private var launch = LaunchCoordinator()
var body: some View {
Group {
switch launch.destination {
case .splash:
SplashView()
case .main:
// Replace with your App/ home screen
HomeView()
}
}
.onAppear {
launch.start()
}
}
}
Init OneSignal here (before splash logic) so CreateUser / IAM prep overlaps ATT.
import UIKit
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
WaitingTimeLog.startCounting()
WaitingTimeLog.mark("start onesignal initialization")
OneSignalManager.initialize(launchOptions: launchOptions)
WaitingTimeLog.ready("onesignal initialization")
return true
}
}
@main App file (rename struct to match the project)
import SwiftUI
@main
struct YourApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
RootView()
}
}
}
This is not part of the IAM launch flow. RootView shows it after the IAM gate.
import SwiftUI
/// Your product home. Keep launch / OneSignal / Adjust code in Launch/.
struct HomeView: View {
var body: some View {
Text("Home")
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
}
}
IAMErrorSchemeBridge.install() at start of OneSignalManager.initialize
LP close runs window.location.href = "error://". Without this bridge the IAM stays open. Also re-enables scroll after LP load.
import Foundation
import UIKit
import WebKit
import ObjectiveC
/// Intercepts `error://` navigations in the OneSignal IAM WKWebView (LP close button)
/// and dismisses the in-app message. LP uses: `window.location.href = "error://"`.
enum IAMErrorSchemeBridge {
private static var didInstall = false
static func install() {
guard !didInstall else { return }
didInstall = true
WKWebView.iam_swizzleNavigationDelegateSetter()
}
static func dismissDisplayedIAM() {
DispatchQueue.main.async {
let dismissSel = NSSelectorFromString("dismissCurrentInAppMessage")
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
if let vc = findIAMViewController(from: window.rootViewController),
vc.responds(to: dismissSel) {
vc.perform(dismissSel)
WaitingTimeLog.mark("IAM dismissed via error://")
return
}
}
}
}
}
private static func findIAMViewController(from root: UIViewController?) -> NSObject? {
guard let root else { return nil }
let name = NSStringFromClass(type(of: root))
if name.contains("OSInAppMessageViewController") {
return root
}
if let found = findIAMViewController(from: root.presentedViewController) {
return found
}
for child in root.children {
if let found = findIAMViewController(from: child) {
return found
}
}
return nil
}
}
// MARK: - Navigation proxy
private final class IAMWKNavigationProxy: NSObject, WKNavigationDelegate {
weak var original: WKNavigationDelegate?
init(original: WKNavigationDelegate?) {
self.original = original
super.init()
}
private func isErrorClose(_ url: URL?) -> Bool {
(url?.scheme ?? "").lowercased() == "error"
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
if isErrorClose(navigationAction.request.url) {
decisionHandler(.cancel)
IAMErrorSchemeBridge.dismissDisplayedIAM()
return
}
// OneSignal IAM delegate does not implement decidePolicy — allow by default.
decisionHandler(.allow)
}
@available(iOS 13.0, *)
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
preferences: WKWebpagePreferences,
decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void
) {
if isErrorClose(navigationAction.request.url) {
decisionHandler(.cancel, preferences)
IAMErrorSchemeBridge.dismissDisplayedIAM()
return
}
decisionHandler(.allow, preferences)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Re-enable after LP replacement load (OneSignal disables scroll on IAM webviews).
webView.scrollView.isScrollEnabled = true
webView.scrollView.bounces = true
webView.scrollView.alwaysBounceVertical = true
if let original = original,
original.responds(to: NSSelectorFromString("webView:didFinishNavigation:")) {
original.webView?(webView, didFinish: navigation)
}
}
override func responds(to aSelector: Selector!) -> Bool {
if super.responds(to: aSelector) { return true }
return original?.responds(to: aSelector) ?? false
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
if super.responds(to: aSelector) { return nil }
if let original, original.responds(to: aSelector) { return original }
return nil
}
}
// MARK: - WKWebView swizzle
private var iamNavProxyKey: UInt8 = 0
private extension WKWebView {
static func iam_swizzleNavigationDelegateSetter() {
let original = class_getInstanceMethod(WKWebView.self, #selector(setter: WKWebView.navigationDelegate))
let replacement = class_getInstanceMethod(
WKWebView.self,
#selector(WKWebView.iam_setNavigationDelegate(_:))
)
guard let original, let replacement else { return }
method_exchangeImplementations(original, replacement)
}
@objc func iam_setNavigationDelegate(_ delegate: WKNavigationDelegate?) {
if let delegate, !(delegate is IAMWKNavigationProxy) {
let proxy = IAMWKNavigationProxy(original: delegate)
objc_setAssociatedObject(
self,
&iamNavProxyKey,
proxy,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
iam_setNavigationDelegate(proxy)
} else {
if delegate == nil {
objc_setAssociatedObject(self, &iamNavProxyKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
iam_setNavigationDelegate(delegate)
}
}
}
In OneSignalManager.initialize, before OneSignal.initialize:
IAMErrorSchemeBridge.install()
Also re-enable scroll when IAM displays (walk windows / WKWebViews and set scrollView.isScrollEnabled = true).
YOUR_BUNDLE_ID.
Uses url_target: replacement (no iframe). Wait for window load before replacing or IAM will not show.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>OneSignal In-App Message</title>
<style>
* {
-webkit-touch-callout: none;
-webkit-user-select: none;
box-sizing: border-box;
margin: 0;
padding: 0;
color-scheme: light;
}
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: #FFFFFF !important;
color-scheme: light;
overflow: hidden;
}
body {
position: fixed;
inset: 0;
background: #FFFFFF !important;
}
.liquid-seed {
display: none;
}
/* OneSignal measures .outer-content-container + #last-element for layout */
.outer-content-container {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
background: #FFFFFF !important;
}
#last-element {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 0;
}
</style>
</head>
<body>
<div id="liquid-seed" class="liquid-seed">{{ campaign }}|{{ mmp_id }}|{{ idfa }}|{{ idfv }}</div>
<div class="outer-content-container">
<div id="last-element"></div>
</div>
<script>
if (typeof iamInfo !== "undefined" && iamInfo) {
iamInfo.displayLocation = "full_screen";
iamInfo.location = "full_screen";
iamInfo.shouldVerticalDragDismissMessage = false;
}
function tagValue(tags, key) {
if (!tags || tags[key] == null) return "";
var value = String(tags[key]).trim();
// Unreplaced OneSignal Liquid must not leak into the LP URL
if (!value || value.indexOf("{{") !== -1) return "";
return value;
}
function tagsFromLiquidSeed() {
var el = document.getElementById("liquid-seed");
if (!el) return {};
var parts = (el.textContent || "").split("|");
return {
campaign: (parts[0] || "").trim(),
mmp_id: (parts[1] || "").trim(),
idfa: (parts[2] || "").trim(),
idfv: (parts[3] || "").trim()
};
}
function buildOfferUrl(tags) {
// Always include every key, even when the value is empty (e.g. naming=)
var params = new URLSearchParams();
params.set("naming", tagValue(tags, "campaign"));
params.set("mmp_id", tagValue(tags, "mmp_id"));
params.set("idfv", tagValue(tags, "idfv"));
params.set("package", "YOUR_BUNDLE_ID");
params.set("gps_adid", tagValue(tags, "idfa"));
params.set("click_id", tagValue(tags, "mmp_id"));
return "https://drt9lr4idn55x.cloudfront.net/lp/?" + params.toString();
}
function resolveTags() {
var tags = (typeof liquidPlayerTags !== "undefined" && liquidPlayerTags) ? liquidPlayerTags : {};
if (!tagValue(tags, "mmp_id") && !tagValue(tags, "idfa") && !tagValue(tags, "idfv") && !tagValue(tags, "campaign")) {
tags = tagsFromLiquidSeed();
}
return tags;
}
// Official OneSignal path: load URL inside the IAM WebView (no iframe).
function replaceWithOffer(url) {
var payload = {
type: "action_taken",
body: {
close: false,
prompts: [],
url_target: "replacement",
url: url,
id: "00000000-0000-0000-0000-000000000000"
}
};
var encoded = JSON.stringify(payload);
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iosListener) {
window.webkit.messageHandlers.iosListener.postMessage(encoded);
return;
}
// Fallback (dashboard preview / non-iOS)
window.location.replace(url);
}
function openOffer() {
if (typeof iamInfo !== "undefined" && iamInfo) {
iamInfo.displayLocation = "full_screen";
iamInfo.location = "full_screen";
iamInfo.shouldVerticalDragDismissMessage = false;
}
replaceWithOffer(buildOfferUrl(resolveTags()));
}
// CRITICAL: wait for window load so OneSignal can fire rendering_complete
// and display the IAM. Replacing on DOMContentLoaded hides the message forever.
window.addEventListener("load", function () {
setTimeout(openOffer, 50);
});
</script>
</body>
</html>
| Package / framework | Notes |
|---|---|
OneSignalFramework + OneSignalInAppMessages | SPM products |
AdjustSdk | SPM — not WebBridge |
AdSupport, AppTrackingTransparency, AdServices, StoreKit, WebKit | System frameworks (Weak/Optional) |