Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
46bf0e3
Fix Android deep-link guidance found by the SDK audit
franco-zalamena-iterable Oct 20, 2026
374ee38
Route React Native through Android first, then iOS
franco-zalamena-iterable Oct 20, 2026
d6e5f77
Prove iOS push for React Native and Expo apps
franco-zalamena-iterable Oct 20, 2026
68592b0
Provision React Native and Expo iOS from the Android setup
franco-zalamena-iterable Oct 20, 2026
2b9f94c
Show each Android provisioning screen's steps in its prompt
franco-zalamena-iterable Oct 20, 2026
795be8e
Bump the plugin to 26.10.3-beta for React Native iOS support
franco-zalamena-iterable Oct 20, 2026
9aa66a6
Carry the iOS skill's Step 0 scope and identity into iOS provisioning
franco-zalamena-iterable Oct 22, 2026
c0fc9ba
Ship the Iterable docs that identity and region decisions depend on
franco-zalamena-iterable Oct 22, 2026
4491025
Route Step 0 decisions to the doc that settles them
franco-zalamena-iterable Oct 22, 2026
d50107b
Fail CI when a Step 0 decision cites a doc that doesn't ship
franco-zalamena-iterable Oct 22, 2026
a5a8130
Put iOS screen summaries in the prompt and bind the newest proof build
franco-zalamena-iterable Oct 22, 2026
b67aaf9
Judge iOS registration from Iterable and flag an app's own push path
franco-zalamena-iterable Oct 22, 2026
7da4e24
Offer the app's own push check as optional, and ask keep-or-replace f…
franco-zalamena-iterable Oct 22, 2026
7206217
Bump the plugin to 26.10.4-beta
franco-zalamena-iterable Oct 22, 2026
a3544a0
Offer the proof identity the device shows, once Iterable ties it to t…
franco-zalamena-iterable Oct 22, 2026
4ffc478
Use neutral app names in test fixtures and eval scenarios
franco-zalamena-iterable Oct 22, 2026
9f3b6fc
Expect the renamed product name in the choose_bundle check
franco-zalamena-iterable Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "iterable-sdk",
"version": "26.10.2-beta",
"version": "26.10.4-beta",
"description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a real device; iOS proof runs on the iOS Simulator.",
"author": { "name": "Iterable" },
"repository": "https://github.com/Iterable/iterable-sdk-skill",
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"owner": { "name": "Iterable" },
"metadata": {
"description": "Iterable's official skills for AI coding assistants — mobile SDK integration guidance.",
"version": "26.10.2-beta"
"version": "26.10.4-beta"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "iterable-sdk",
"version": "26.10.2-beta",
"version": "26.10.4-beta",
"description": "Takes an Android or iOS app — native or React Native — from nothing to a push notification proven on a device: Firebase or Apple (APNs) provisioning, version-pinned SDK integration, silent-failure pitfalls, and device-side proof that a push actually arrived. Android proof runs on a real device; iOS proof runs on the iOS Simulator.",
"author": { "name": "Iterable" },
"repository": "https://github.com/Iterable/iterable-sdk-skill",
Expand Down
24 changes: 24 additions & 0 deletions INTENDED-FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,30 @@ Test Push remains optional troubleshooting and never blocks completion.
Only missing verification credentials return to their narrow capture command.
Missing proof metadata is not evidence that the SDK integration is missing.

## React Native path

A React Native app runs the Android path and then the iOS path, one after the
other, under the `iterable-react-native` skill:

1. Record the scope once — features, identity mode, region, JWT policy. Both
platforms use that one record.
2. **Android first.** The Android ladder provisions Firebase and Iterable, the
skill writes the integration, and `iterable-android-verify` proves a push.
No iOS step starts before that verdict is green.
3. **iOS second.** The iOS provider path offers to reuse both Iterable keys,
the region, No JWT, and the test email from the Android setup, carries over
the feature scope from step 1 (passed with `--features`, shown again for
confirmation), and adds the Apple/APNs prerequisites. The skill writes the
native push code (bare) or relies on the Expo plugin, and
`iterable-ios-verify` proves a push on a Release Simulator build.
4. The app is integrated when the verifier has passed for every platform in
scope. One green platform is not done.

The Mobile key reaches the app as a gitignored file the tool writes — bare
React Native `.iterable/rn/iterable.local.ts`, Expo `EXPO_PUBLIC_ITERABLE_API_KEY`
in `.env.local` — and the app loads it so that a checkout without the file
still builds.

## What divergence looks like

The three classes, because they need different fixes and get confused constantly:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ before reading or changing scoped cloud resources.
| `iterable-android` | Integrates and troubleshoots Iterable's native Android SDK using version-pinned documentation. |
| `iterable-android-verify` | Independently verifies native Android and React Native Android integrations and push delivery. |
| `iterable-ios` | Integrates and troubleshoots Iterable's native iOS SDK using Iterable documentation and iOS lifecycle guidance. |
| `iterable-ios-verify` | Independently verifies an existing native iOS integration, using no-app-edits behavioral proof by default and optional instrumented diagnostics. |
| `iterable-ios-verify` | Independently verifies a native iOS, React Native iOS, or Expo iOS integration, using no-app-edits behavioral proof by default and optional instrumented diagnostics. |
| `iterable-react-native` | Integrates and troubleshoots `@iterable/react-native-sdk`, including Expo projects. |

The skills can work independently. A new push integration normally follows:
Expand Down
8 changes: 4 additions & 4 deletions REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ commit:
important question in the review. The pitfalls encode silent-failure traps;
if Iterable's docs now recommend something a pitfall warns against, one of the
two is wrong. Fix it promptly — a corpus that argues with itself is live.
- [ ] **Did headings move?** `SKILL.md`'s routing table points at slugs, and the
upgrade row points at `## Upgrading the SDK` inside `android-sdk`. If a
referenced section was renamed, the routing table now points at nothing —
fix it in a follow-up.
- [ ] **Did headings move?** `tests/decision-docs.sh` fails when a slug or
section named in a Step 0 decisions table stops resolving. The routing tables
further down `SKILL.md` aren't checked: if a section they name was renamed,
they now point at nothing — fix it in a follow-up.
- [ ] **The transform only reshaped, never changed meaning.** Boilerplate
removed, callouts converted, blank lines collapsed — but no claim added,
weakened, or reversed. A *content* change not explained by an upstream edit
Expand Down
7 changes: 5 additions & 2 deletions bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ front end.

Shared helpers and presentation logic live in `bin/config.sh`. Device-specific
checks live in `bin/gates-device.sh`; Iterable checks live in
`bin/gates-iterable.sh`.
`bin/gates-iterable.sh`. `bin/existing-push.js` finds an app's own push path in
its source for both platforms; a done verdict only offers an optional check
for it, and no gate reads it.

### Native iOS entry points

Expand All @@ -109,7 +111,8 @@ evidence differ from Firebase and Android device evidence.
Verification starts with `ios-verify status`, even in a clean conversation; with
no proof run it returns the one-answer proof screen. `ios-verify inspect` reads
the project without interpreting a missing `.iterable/ios/` workspace as a
missing SDK integration. Behavioral proof needs no app edits: existing
missing SDK integration; for React Native and Expo it reads `package.json`,
the JavaScript sources, and the native hooks in `ios/`, never `node_modules`. Behavioral proof needs no app edits: existing
identity, selected device, installed app, one targeted marker, and, on a
Simulator, APNs delivery read from the Simulator's own records by `ios-prove`
and re-derived by `ios-verify`. A standalone run
Expand Down
29 changes: 29 additions & 0 deletions bin/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ load_env_file "$WS/resolved.env"

: "${PID:=}"
: "${PACKAGE:=}"

# Prints "<package>\t<file>", or nothing when no single applicationId is readable.
project_package() {
local root="${PROJECT_ROOT:-$(dirname "$WS")}" f value="" source=""
if [[ -f "$root/app.json" ]]; then
value="$(node -e '
try {
const app = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
process.stdout.write(String(app?.expo?.android?.package || ""));
} catch {}
' "$root/app.json" 2>/dev/null)"
[[ -z "$value" ]] || source="app.json"
fi
if [[ -z "$value" ]]; then
for f in android/app/build.gradle android/app/build.gradle.kts app/build.gradle app/build.gradle.kts; do
[[ -f "$root/$f" ]] || continue
value="$(sed -nE "s/^[[:space:]]*applicationId[[:space:]]*(=[[:space:]]*)?[\"']([A-Za-z][A-Za-z0-9_.]*)[\"'].*/\\2/p" "$root/$f" | sort -u)"
source="$f"
break
done
fi
[[ -n "$value" && "$value" != *$'\n'* && "$value" == *.* ]] || return 0
printf '%s\t%s' "$value" "$source"
}
: "${SA_ID:=itbl-onboard-fcm}"
: "${APP_DISPLAY_NAME:=Iterable Onboard Test}"

Expand Down Expand Up @@ -1305,6 +1329,11 @@ next_action() {
elif [[ -f "$WS/state.tsv" ]]; then
a_owner=none; a_kind=done
a_summary="a push reached the device — nothing left to do"
# Offered, never a gate: whether the app's own push still shows is the developer's call.
local follow
follow="$(node "$BIN/existing-push.js" scan android "${PROJECT_ROOT:-$(dirname "$WS")}" 2>/dev/null |
node "$BIN/existing-push.js" follow-up 2>/dev/null)"
[[ -z "$follow" ]] || a_summary="$a_summary. $follow"
else
a_owner=agent; a_kind=run_ladder; a_cmd="$BIN/agent"
a_summary="nothing has been checked yet"
Expand Down
230 changes: 230 additions & 0 deletions bin/existing-push.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
#!/usr/bin/env node
// Finds an app's own (non-Iterable) push handling in its source. Heuristic, like
// every source signal; Iterable-only wiring, including an `itbl` branch, is not one.
//
// existing-push.js scan ios <app-root> (NUL-separated file list on stdin)
// existing-push.js scan android <app-root> (walks the app root itself)
// existing-push.js follow-up (a scan's JSON on stdin)
//
// `scan` prints a JSON array of {signal, file, detail}. `follow-up` prints the
// optional after-integration line for a done verdict, or nothing.
//
// iOS takes its list from ios_project_find_native so every iOS scan prunes alike.

"use strict";
const fs = require("fs");
const path = require("path");

// Comments go; string bodies stay (for "itbl") with brackets blanked so braces still pair.
function strip(src) {
let out = "", i = 0;
while (i < src.length) {
if (src.startsWith("//", i)) { while (i < src.length && src[i] !== "\n") i++; continue; }
if (src.startsWith("/*", i)) {
const end = src.indexOf("*/", i + 2), stop = end < 0 ? src.length : end + 2;
out += src.slice(i, stop).replace(/[^\n]/g, " "); i = stop; continue;
}
if (src[i] === "\"") {
const q = src.startsWith("\"\"\"", i) ? "\"\"\"" : "\"";
let j = i + q.length;
while (j < src.length && !src.startsWith(q, j) && (q.length === 3 || src[j] !== "\n")) j += src[j] === "\\" ? 2 : 1;
const closed = src.startsWith(q, j);
out += q + src.slice(i + q.length, j).replace(/[(){}\[\]]/g, " ") + (closed ? q : "");
i = closed ? j + q.length : j; continue;
}
out += src[i++];
}
return out;
}

function statements(body) {
const out = []; let cur = "", depth = 0;
for (const ch of body) {
if (ch === "(" || ch === "[") depth++;
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
if (depth === 0 && "\n;{}".includes(ch)) { if (cur.trim()) out.push(cur.trim()); cur = ""; continue; }
cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out;
}

const plumbing = [
/Iterable|\bITB|itbl/,
/^(return\s+)?(try\s+)?(await\s+)?(self\.)?(completionHandler|completion|handler|contentHandler|reply)\s*\??\s*\(/,
/\bcontinuation\s*\.\s*resume\b/,
/^(return\s+)?(super\s*\.|\[\s*super\b)/,
/^(print|debugPrint|NSLog|os_log)\s*\(/,
/^(self\.)?(log(ger)?|timber)\b/i,
/^(return\s+)?(try\s+)?(await\s+)?(Task|DispatchQueue\.[\w.]+\.async|MainActor\.run|with(Checked|Unsafe)(Throwing)?Continuation)\b/
];
const keywords = new Set(["if", "while", "for", "switch", "when", "guard", "return", "catch", "case",
"available", "sizeof", "await", "try", "synchronized"]);
const acts = s => [...s.matchAll(/([A-Za-z_]\w*)\s*\??\s*\(/g)].some(m => !keywords.has(m[1])) ||
/\[\s*[A-Za-z_][\w.]*\s+[A-Za-z_]/.test(s);
const doesMore = body => statements(body).some(s => !plumbing.some(p => p.test(s)) && acts(s));

function bodyAt(code, open) {
let depth = 0;
for (let i = open; i < code.length; i++) {
if (code[i] === "{") depth++;
else if (code[i] === "}" && --depth === 0) return code.slice(open + 1, i);
}
return code.slice(open + 1);
}

const providers = {
ios: [
["Firebase Messaging", /\bFirebaseMessaging\b|\bFirebase\/Messaging\b|\bMessaging\.messaging\s*\(/],
["OneSignal", /\bOneSignal/],
["Airship", /\bAirship(Kit|Core)\b|\bUAirship\b|\bAirship\.takeOff\b/],
["Braze", /\bBrazeKit\b|\bAppboy/],
["CleverTap", /\bCleverTap/],
["MoEngage", /\bMoEngage/],
["Pushwoosh", /\bPushwoosh/]
],
// Not firebase-messaging: Iterable's own Android SDK depends on it.
android: [
["OneSignal", /\bcom\.onesignal\b/i],
["Braze", /\bcom\.(braze|appboy)\b/],
["Airship", /\bcom\.urbanairship\b/],
["CleverTap", /\bcom\.clevertap\b/],
["MoEngage", /\bcom\.moengage\b/],
["Pushwoosh", /\bcom\.pushwoosh\b/]
]
};

const jsProviders = [
["Firebase Messaging", "@react-native-firebase/messaging"], ["OneSignal", "react-native-onesignal"],
["OneSignal", "onesignal-expo-plugin"], ["Airship", "@ua/react-native-airship"], ["Braze", "@braze/react-native-sdk"],
["CleverTap", "clevertap-react-native"], ["MoEngage", "react-native-moengage"], ["Pushwoosh", "pushwoosh-react-native-plugin"],
["push-notification-ios", "@react-native-community/push-notification-ios"],
["react-native-push-notification", "react-native-push-notification"],
["react-native-notifications", "react-native-notifications"], ["expo-notifications", "expo-notifications"]
];

function collector(root) {
const found = [], seen = new Set();
const add = (signal, file, detail) => {
if (seen.has(signal + detail)) return;
seen.add(signal + detail);
found.push({ signal, file: path.relative(root, file), detail });
};
return { found, add };
}

function read(file) { try { return fs.readFileSync(file, "utf8") } catch { return null } }

function packageJson(root, add) {
let pkg = {}; try { pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")) } catch {}
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
for (const [name, dep] of jsProviders) if (dep in deps)
add("push_sdk", path.join(root, "package.json"), `${name} (${dep}) is a dependency; it may own or swizzle the notification callbacks`);
}

function scanIos(root, files) {
const { found, add } = collector(root);
const callbacks = [
[/^application$/, /\bdidRegisterForRemoteNotificationsWithDeviceToken\b/, "didRegisterForRemoteNotificationsWithDeviceToken"],
[/^application$/, /\bdidReceiveRemoteNotification\b/, "didReceiveRemoteNotification"],
[/^userNotificationCenter$/, /\bwillPresent(Notification)?\b/, "userNotificationCenter willPresent"],
[/^userNotificationCenter$/, /\bdidReceive(NotificationResponse)?\b/, "userNotificationCenter didReceive"]
];
for (const file of files) {
const src = read(file); if (src == null) continue;
const base = path.basename(file);
const code = /^(Podfile|project\.yml)$/.test(base) ? src.replace(/^\s*#.*$/gm, "") : strip(src);
for (const [name, re] of providers.ios) if (re.test(code)) add("push_sdk", file, `${name} is linked; it may own or swizzle the notification callbacks`);
if (!/\.(swift|mm?)$/.test(base)) continue;
const starts = /\bfunc\s+(application|userNotificationCenter)\s*\(|^[ \t]*-\s*\([^)]*\)\s*(application|userNotificationCenter)\s*:/gm;
for (const m of code.matchAll(starts)) {
const open = code.indexOf("{", m.index);
if (open < 0) continue;
const signature = code.slice(m.index, open), owner = m[1] || m[2];
const callback = callbacks.find(([o, label]) => o.test(owner) && label.test(signature));
if (callback && doesMore(bodyAt(code, open))) add("notification_callback", file, `${callback[2]} does more than forward to Iterable`);
}
if (/\boverride\s+func\s+didReceive\s*\(\s*_\s+\w+\s*:\s*UNNotificationRequest|-\s*\(\s*void\s*\)\s*didReceiveNotificationRequest\s*:/.test(code) &&
/\bcontentHandler\s*\??\s*\(/.test(code))
add("notification_service_extension", file, "a Notification Service Extension that hands back content itself, so it also handles non-Iterable payloads");
}
packageJson(root, add);
return found;
}

const androidPrune = new Set(["build", "node_modules", ".git", ".gradle", ".iterable", ".idea", "ios"]);
function walkAndroid(dir, out) {
let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return out }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (androidPrune.has(e.name) || (path.basename(dir) === "src" && /^(test|androidTest)/.test(e.name))) continue;
walkAndroid(full, out);
} else if (/\.(kt|java|gradle|gradle\.kts|toml)$/.test(e.name) || e.name === "AndroidManifest.xml") {
out.push(full);
}
}
return out;
}

// A body's opening brace after the parameter list, or an expression body (`= …`) to the end of the line.
function functionBody(code, at) {
let i = code.indexOf("(", at), depth = 0;
for (; i < code.length; i++) {
if (code[i] === "(") depth++;
else if (code[i] === ")" && --depth === 0) break;
}
const rest = code.slice(i + 1), lead = rest.match(/^[^{=;]*/)[0], next = rest[lead.length];
if (next === "{") return bodyAt(code, i + 1 + lead.length);
if (next === "=") return rest.slice(lead.length + 1).split("\n")[0];
return "";
}

function scanAndroid(root) {
const { found, add } = collector(root);
const files = walkAndroid(root, []);
const forwardOnly = new Set(), manifests = [];
for (const file of files) {
const src = read(file); if (src == null) continue;
if (path.basename(file) === "AndroidManifest.xml") { manifests.push([file, src]); continue; }
const code = file.endsWith(".toml") ? src.replace(/^\s*#.*$/gm, "") : strip(src);
for (const [name, re] of providers.android) if (re.test(code)) add("push_sdk", file, `${name} is linked; it may own or show pushes`);
if (!/\.(kt|java)$/.test(file)) continue;
for (const m of code.matchAll(/\bclass\s+(\w+)[^{]*?(:|\bextends)\s*(com\.google\.firebase\.messaging\.)?FirebaseMessagingService\b/g)) {
const own = [...code.matchAll(/\b(fun|void)\s+(onMessageReceived|onNewToken)\s*\(/g)]
.filter(f => doesMore(functionBody(code, f.index))).map(f => f[2]);
if (own.length) add("messaging_service", file, `${m[1]} handles ${own.join(" and ")} itself, beyond forwarding to Iterable`);
else forwardOnly.add(m[1]);
}
}
for (const [file, src] of manifests) {
const xml = src.replace(/<!--[\s\S]*?-->/g, "");
for (const m of xml.matchAll(/<service\b([^>]*)>([\s\S]*?)<\/service>/g)) {
const name = (m[1].match(/android:name\s*=\s*"([^"]+)"/) || [])[1] || "";
if (!m[2].includes("com.google.firebase.MESSAGING_EVENT")) continue;
if (/Iterable/.test(name) || forwardOnly.has(name.split(".").pop())) continue;
add("messaging_event_filter", file, `${name || "a service"} receives com.google.firebase.MESSAGING_EVENT`);
}
}
packageJson(root, add);
return found;
}

function followUp(found) {
if (!Array.isArray(found) || !found.length) return "";
return `Iterable push is proven. The app also has its own push path (${found.map(f => `${f.detail} in ${f.file}`).join("; ")}). If the developer wants, offer the optional after-integration check that the app's own notifications still show.`;
}

const [command, platform, root] = process.argv.slice(2);
const stdin = () => { try { return fs.readFileSync(0, "utf8") } catch { return "" } };
if (command === "scan" && platform === "ios" && root) {
process.stdout.write(JSON.stringify(scanIos(root, stdin().split("\0").filter(Boolean))));
} else if (command === "scan" && platform === "android" && root) {
process.stdout.write(JSON.stringify(scanAndroid(root)));
} else if (command === "follow-up") {
let found = []; try { found = JSON.parse(stdin()) } catch {}
process.stdout.write(followUp(found));
} else {
process.stderr.write("usage: existing-push.js scan ios|android <app-root> | follow-up\n");
process.exit(2);
}
Loading
Loading