From e3ae1ed0feee6df68bff7c1c73a5bd1f82b94a39 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 12 May 2026 15:10:01 +0300 Subject: [PATCH 01/21] Update .gitignore for E2E reports, example app artifacts, and tooling --- .gitignore | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2d311999..d88e222d 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,31 @@ demos/appsflyer-expo-app/yarn.lock demos/appsflyer-react-native-app/ios/.xcode.env demos/appsflyer-react-native-app/ios/AppsFlyerExample.xcodeproj/project.pbxproj -.cursor/* \ No newline at end of file +.cursor/* + +# Local MCP config +.mcp.json + +# Claude-generated planning/skill docs +Docs/plans/ +Docs/superpowers/ + +# Test app secrets +example/.env + +# Example app generated artifacts +example/ios/Podfile.lock +example/ios/.xcode.env.local +example/ios/.af-e2e/ +example/android/app/.cxx/ +example/android/app/debug.keystore +example/android/gradlew +example/android/gradlew.bat +example/package-lock.json + +# Scenario runner reports +.af-e2e/reports/ +.af-smoke/reports/ + +# RC smoke synthesized app (created at CI time) +example_rc_smoke/ \ No newline at end of file From 87f0af27d126e2437aee81ee753b2182ac2a822e Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 12 May 2026 15:10:10 +0300 Subject: [PATCH 02/21] Add Claude Code project config and rules --- .claude/.gitignore | 3 + .claude/agents/bridge-audit.md | 68 ++++++++++++++ .claude/commands/release-check.md | 58 ++++++++++++ .claude/commands/version-bump.md | 46 +++++++++ .claude/rules/bridge-patterns.md | 60 ++++++++++++ .claude/rules/expo-config.md | 60 ++++++++++++ .claude/rules/known-issues-kb.md | 140 ++++++++++++++++++++++++++++ .claude/rules/native-android.md | 68 ++++++++++++++ .claude/rules/native-ios.md | 65 +++++++++++++ .claude/rules/release-versioning.md | 94 +++++++++++++++++++ .claude/rules/testing.md | 78 ++++++++++++++++ .claude/rules/typescript-types.md | 56 +++++++++++ .claude/settings.json | 52 +++++++++++ CLAUDE.md | 74 +++++++++++++++ 14 files changed, 922 insertions(+) create mode 100644 .claude/.gitignore create mode 100644 .claude/agents/bridge-audit.md create mode 100644 .claude/commands/release-check.md create mode 100644 .claude/commands/version-bump.md create mode 100644 .claude/rules/bridge-patterns.md create mode 100644 .claude/rules/expo-config.md create mode 100644 .claude/rules/known-issues-kb.md create mode 100644 .claude/rules/native-android.md create mode 100644 .claude/rules/native-ios.md create mode 100644 .claude/rules/release-versioning.md create mode 100644 .claude/rules/testing.md create mode 100644 .claude/rules/typescript-types.md create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 00000000..ae338f36 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,3 @@ +settings.local.json +hooks/logs/ +agent-memory-local/ diff --git a/.claude/agents/bridge-audit.md b/.claude/agents/bridge-audit.md new file mode 100644 index 00000000..2c8d2c1c --- /dev/null +++ b/.claude/agents/bridge-audit.md @@ -0,0 +1,68 @@ +--- +name: bridge-audit +description: Audit JS-to-native bridge for API consistency across iOS and Android platforms +model: sonnet +maxTurns: 20 +allowedTools: + - Read + - Bash + - Grep + - Glob +--- + +## Bridge Audit Agent + +Verify that every method exposed in `index.js` has matching implementations on both iOS and Android, with correct type definitions. + +### Execution Contract (non-negotiable) + +You MUST read and compare the actual source files. You are forbidden from: +- Guessing method names or signatures from memory +- Skipping any method found in index.js +- Marking a method as "ok" without reading the native implementation + +### Files to audit + +| Layer | File | +|-------|------| +| JS API | `index.js` | +| Types | `index.d.ts` | +| iOS bridge | `ios/RNAppsFlyer.m` | +| Android bridge | `android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java` | + +### For each method in index.js + +1. Extract the method name and the native method it calls (e.g., `RNAppsFlyer.initSdkWithCallBack`) +2. Check iOS: find matching `RCT_EXPORT_METHOD` in `RNAppsFlyer.m` +3. Check Android: find matching `@ReactMethod` in `RNAppsFlyerModule.java` +4. Check types: find matching declaration in `index.d.ts` +5. Compare parameter counts and types across all layers + +### Output format + +```markdown +## Bridge Audit Report + +| Method | JS | iOS | Android | Types | Status | +|--------|-----|-----|---------|-------|--------| +| initSdk | ok | ok | ok | ok | PASS | +| logEvent | ok | ok | ok | missing | FAIL | +| ... | ... | ... | ... | ... | ... | + +### Issues Found +1. `methodName` — [description of mismatch] + +### Summary +- Total methods: N +- Passing: N +- Failing: N +- Missing on iOS: N +- Missing on Android: N +- Missing types: N +``` + +### Fail-closed guardrail + +If any source file cannot be read, STOP and report which file is missing. Do not produce a partial audit. + +DO NOT COMMIT any code. This is a read-only audit. diff --git a/.claude/commands/release-check.md b/.claude/commands/release-check.md new file mode 100644 index 00000000..d377aaee --- /dev/null +++ b/.claude/commands/release-check.md @@ -0,0 +1,58 @@ +--- +name: release-check +description: Validate release readiness across all checkpoints +allowed-tools: + - Read + - Bash + - Grep + - Glob +--- + +## Release Readiness Check + +Verify all release checkpoints. Report as a pass/fail checklist. + +### Checks to run + +1. **Version sync** — All 4 version constants match: + - `package.json` version + - `react-native-appsflyer.podspec` s.version + - `ios/RNAppsFlyer.h` kAppsFlyerPluginVersion + - `android/.../RNAppsFlyerConstants.java` PLUGIN_VERSION + +2. **CHANGELOG** — `CHANGELOG.md` has an entry for the current version at the top. + +3. **Tests** — `npm test` passes with no failures. + +4. **Types** — `npx tsc --noEmit` passes (PurchaseConnector TypeScript check). + +5. **Lint** — `npm run lint` passes with no errors. + +6. **Production logging** — No `console.log` calls in `index.js` that aren't inside a callback fallback pattern. Grep and report any found. + +7. **Type exports** — Every named export from `index.js` has a matching declaration in `index.d.ts`. List any mismatches. + +8. **Git state** — `git status` shows no uncommitted changes (warn if there are staged changes, that's expected pre-commit). + +### Output format + +```markdown +## Release Readiness: vX.Y.Z + +- [x] Version sync — all 4 files match (X.Y.Z) +- [x] CHANGELOG — entry found for X.Y.Z +- [ ] Tests — 2 failures (list them) +- [x] Types — clean +- [x] Lint — clean +- [x] Production logging — no leaks +- [ ] Type exports — missing: AppsFlyerPurchaseConnectorConfig +- [x] Git state — clean + +**Result: NOT READY** (2 checks failed) +``` + +### Fail-closed guardrail + +If `npm test` or `npx tsc` cannot run (missing node_modules, broken config), report the setup error. Do not skip the check or mark it as passed. + +DO NOT COMMIT or fix anything. This is a read-only audit. diff --git a/.claude/commands/version-bump.md b/.claude/commands/version-bump.md new file mode 100644 index 00000000..f4a5c84f --- /dev/null +++ b/.claude/commands/version-bump.md @@ -0,0 +1,46 @@ +--- +name: version-bump +description: Bump plugin version across all 4 version surface files +argument-hint: [new-version e.g. 6.18.0] +allowed-tools: + - Read + - Edit + - Bash + - AskUserQuestion +--- + +## Version Bump + +Bump the plugin version to `$ARGUMENTS` across all 4 files that must stay in sync. + +### Version surface files + +1. `package.json` — `"version": "X.Y.Z"` +2. `react-native-appsflyer.podspec` — `s.version = 'X.Y.Z'` +3. `ios/RNAppsFlyer.h` — `kAppsFlyerPluginVersion = @"X.Y.Z"` +4. `android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java` — `PLUGIN_VERSION = "X.Y.Z"` + +### Steps + +1. If `$ARGUMENTS` is empty or not a valid semver, ask the user for the target version using AskUserQuestion. + +2. Read all 4 files. Extract the current version from each. Verify they all match. If they don't match, STOP and report the mismatch — do not proceed with inconsistent state. + +3. Show the user: `Current: X.Y.Z → Target: A.B.C` and confirm. + +4. Edit all 4 files, replacing only the version string. + +5. Run `npm test` to verify nothing broke. + +6. Show a summary: + ``` + Version bump: X.Y.Z → A.B.C + Files updated: 4/4 + Tests: passed/failed + ``` + +### Fail-closed guardrail + +If any file cannot be read, or the version pattern is not found in any file, STOP and report which file failed. Do NOT partially update — all 4 files must be updated atomically or none. + +DO NOT COMMIT. The user commits. diff --git a/.claude/rules/bridge-patterns.md b/.claude/rules/bridge-patterns.md new file mode 100644 index 00000000..a8550b9f --- /dev/null +++ b/.claude/rules/bridge-patterns.md @@ -0,0 +1,60 @@ +--- +paths: + - "index.js" + - "index.d.ts" +--- + +# Bridge patterns — JS ↔ native contract + +Scope: `index.js`, `index.d.ts`, and any file that calls `NativeModules.RNAppsFlyer` or `NativeModules.PCAppsFlyer`. + +## 1. Three API patterns coexist + +| Pattern | When used | Detection | +|---------|-----------|-----------| +| Dual callback/promise | `initSdk`, `logEvent` | `if (success && error)` routes to `*WithCallBack`; otherwise `*WithPromise` | +| Callback-only | Most config methods (`setCustomerUserId`, `stop`, `setCurrencyCode`) | Optional callback; defaults to `console.log` fallback | +| Event emitter | Deep linking, conversion data, purchase validation | `appsFlyerEventEmitter.addListener(eventName, handler)` | + +When adding a new method, match the pattern of similar methods. Do not mix patterns within a single method. + +## 2. Callback-to-native routing + +```js +// Dual pattern — index.js +if (success && error) { + RNAppsFlyer.initSdkWithCallBack(options, success, error); +} else { + return RNAppsFlyer.initSdkWithPromise(options); +} +``` + +The native side has **separate methods** for callback vs promise variants. Adding a new dual method requires implementing both on iOS (`RCT_EXPORT_METHOD`) and Android (`@ReactMethod`). + +## 3. Event emitter contract + +- Events arrive as **JSON strings** from native — always parsed with `JSON.parse` on the JS side +- Parse failures produce `AFParseJSONException` objects (not proper Error subclasses) +- Native must serialize data to JSON string **before** calling `sendEventWithName:body:` (iOS) or `sendEvent` (Android) +- Supported event names are declared in iOS `supportedEvents` and must match exactly on both platforms: + `onAttributionFailure`, `onAppOpenAttribution`, `onInstallConversionFailure`, `onInstallConversionDataLoaded`, `onDeepLinking`, `onValidationResult` + +## 4. Listener registration order + +`onDeepLink` (and `onInstallConversionData`, `onAppOpenAttribution`) must be registered **before** `initSdk`. The native SDK fires these callbacks immediately after initialization — if the JS listener isn't attached yet, events are lost silently. + +This is the #1 source of GitHub issues (#650, #647, #630, #305, #292). Always validate listener timing in code review. + +## 5. No transpilation + +`index.js` ships as-is via npm — no Babel, no bundler. Write only syntax that Metro and Node can consume directly. The file uses ES module `export` syntax with CommonJS-compatible patterns. + +## 6. Named exports + +Current named exports from `index.js`: `AppsFlyerConsent`, `AFParseJSONException`, `AFPurchaseType`, `MEDIATION_NETWORK`, `StoreKitVersion`, `AppsFlyerPurchaseConnector`, `AppsFlyerPurchaseConnectorConfig`. + +Adding a new named export changes the public API surface — requires a minor version bump and matching `index.d.ts` update. + +## 7. Default callback fallback + +Many methods use `(result) => console.log(result)` as the default callback when none is provided. This leaks to production logs. Prefer silent no-ops for new methods, or document the logging behavior explicitly. diff --git a/.claude/rules/expo-config.md b/.claude/rules/expo-config.md new file mode 100644 index 00000000..1bee1a1e --- /dev/null +++ b/.claude/rules/expo-config.md @@ -0,0 +1,60 @@ +--- +paths: + - "expo/**" +--- + +# Expo config plugin rules + +Scope: `expo/` directory — `withAppsFlyer.js`, `withAppsFlyerIos.js`, `withAppsFlyerAndroid.js`. + +## 1. Config plugin structure + +``` +expo/ +├── withAppsFlyer.js ← Entry point, composes iOS + Android plugins +├── withAppsFlyerIos.js ← Modifies AppDelegate for deep link handling +├── withAppsFlyerAndroid.js ← Modifies AndroidManifest.xml +└── withAppsFlyerAppDelegate.js ← AppDelegate code injection +``` + +These are Expo Config Plugins — they run at `expo prebuild` time to modify native project files. + +## 2. Swift AppDelegate problem (critical, unresolved) + +Starting with Expo SDK 52 / RN 0.76, the default AppDelegate is **Swift** (not Objective-C). The plugin's `withAppsFlyerAppDelegate.js` modifies ObjC code and **fails silently** on Swift AppDelegates (#638, #620). + +Until this is fixed: +- Do not assume AppDelegate is ObjC in config plugin code +- Test with both `expo prebuild` (Swift default) and legacy ObjC projects +- This is the #1 Expo compatibility blocker + +## 3. Manifest merge duplication + +`withAppsFlyerAndroid.js` appends `tools:replace` entries to `AndroidManifest.xml`. Running `expo prebuild` multiple times (without `--clean`) causes **duplicate entries** that break the Android build (#672). + +Fix pattern: always check if the entry exists before appending. Use idempotent modifications. + +## 4. Expo Go incompatibility + +The plugin requires native modules unavailable in Expo Go. Only works in development builds (`eas build --profile development`) or bare workflow. This is documented but users miss it repeatedly (#542). + +## 5. No test coverage + +The Expo config plugins have **zero test coverage**. When modifying these files, manual testing with `expo prebuild --clean` on both platforms is required. Consider adding unit tests that mock the Expo config plugin API. + +## 6. Peer dependency + +`expo` is declared as an optional peer dependency. The plugin must work without Expo installed — guard all Expo-specific imports and config. + +## 7. Testing changes + +```bash +# Clean prebuild (recommended) +cd demos/demo && npx expo prebuild --clean + +# Verify Android manifest +cat android/app/src/main/AndroidManifest.xml | grep -A5 "appsflyer" + +# Verify iOS AppDelegate +cat ios/demo/AppDelegate.m # or AppDelegate.swift for Expo 52+ +``` diff --git a/.claude/rules/known-issues-kb.md b/.claude/rules/known-issues-kb.md new file mode 100644 index 00000000..e71f0d48 --- /dev/null +++ b/.claude/rules/known-issues-kb.md @@ -0,0 +1,140 @@ +# Known issues knowledge base + +Issue-based KB derived from real GitHub issues. Reference when debugging user reports, reviewing PRs, or adding new features. + +## Deep linking (62 issues — #1 category) + +### Listener not firing +**Issues:** #650, #647, #630, #305, #292 +**Root cause:** `onDeepLink` registered after `initSdk`, or native AppDelegate/MainActivity setup missing. +**Fix:** Register listeners before `initSdk`. Verify `continueUserActivity`/`openURL` in AppDelegate, intent filters in AndroidManifest. +**Test:** Killed state → open deep link → verify callback fires within 5s. + +### Deferred deep link not working +**Issues:** #650 (Android), #305 (iOS) +**Root cause:** Conversion data round-trip is slow or fails. No "completed with no result" callback. +**Fix:** Use `onInstallConversionData` as fallback. Check `is_first_launch` flag. + +### Inconsistent payload shape +**Issues:** #292, #242 +**Root cause:** Android returns stringified JSON where iOS returns an object in some versions. +**Fix:** Always `JSON.parse` if typeof is string. Type definitions should reflect the union. + +## iOS build failures (22 issues) + +### Header not found +**Issues:** #633 (`AppsFlyerConsent.h`), #602 (`AppsFlyerAdRevenueData.h`), #646 (`react_native_appsflyer-Swift.h`) +**Root cause:** Podspec pins native SDK version; cached pods have stale headers. +**Fix:** `pod deintegrate && pod install --repo-update`. Match plugin version to compatible native SDK. + +### Symbol collision +**Issues:** #497, #541 (redefinition of `SUCCESS`) +**Root cause:** Native SDK enum name collides with other libraries. +**Fix:** Upgrade to plugin version where enum was namespaced. + +## Android build failures (13 issues) + +### Namespace not specified +**Issues:** #583, #561 +**Root cause:** AGP 8.0+ requires `namespace` in build.gradle. Plugin pre-6.15.1 lacks it. +**Fix:** Upgrade plugin to 6.15.1+. + +### AndroidManifest merge conflicts +**Issues:** #627, #631 +**Root cause:** Plugin manifest declares `tools:replace` that conflicts with other libraries. +**Fix:** Add explicit `tools:replace` in app's main AndroidManifest.xml. + +## Native module null / not found (13 issues) + +### RNAppsFlyer is null +**Issues:** #587, #401, #174, #333 +**Root cause:** Autolinking not triggered after install, or New Architecture enabled with old plugin version. +**Fix:** Run `pod install` (iOS) / Gradle sync (Android). For New Architecture: upgrade to 6.15.1+. Restart Metro: `npx react-native start --reset-cache`. + +## Expo compatibility (18 issues) + +### Swift AppDelegate not supported +**Issues:** #638, #620 +**Root cause:** Config plugin only modifies ObjC AppDelegate. Expo 52+ defaults to Swift. +**Fix:** Pending upstream fix. Workaround: manual native setup. + +### Duplicate manifest entries +**Issues:** #672 +**Root cause:** `withAppsFlyerAndroid.js` not idempotent. +**Fix:** Use `expo prebuild --clean` (not just `expo prebuild`). + +## Runtime crashes (18 issues) + +### Double callback invocation +**Issues:** #601 +**Root cause:** Native bridge calls JS callback more than once. +**Fix:** `CallbackGuard` added in 6.17.8 (Android). Every new callback method must use it. + +### ConcurrentModificationException +**Issues:** #447 +**Root cause:** Thread safety issue in native Android SDK. +**Fix:** Upgrade native SDK to patched version. + +## Event tracking / logEvent (13 issues) + +### 404 on logEvent +**Issues:** #491, #390 +**Root cause:** Wrong `appId` on Android (should be package name or omitted, not iOS App Store ID). +**Fix:** Use `Platform.select()` for `appId`. On Android: omit or use package name. + +### "no devKey" error +**Issues:** #645 +**Root cause:** `logEvent` called before `initSdk` completes. +**Fix:** Await `initSdk` resolution before calling `logEvent`. + +## Privacy / ATT / compliance (20 issues) + +### ITMS-91064 App Store rejection +**Issues:** #673 +**Root cause:** `static_framework = true` places PrivacyInfo.xcprivacy where Apple's tooling doesn't scan. +**Fix:** Use dynamic linking (`static_framework = false`). + +### ATT popup not showing +**Issues:** #328, #619 +**Root cause:** `waitForATTUserAuthorization` must be set before `start()`. User must be prompted first. +**Fix:** Call `requestTrackingAuthorization` before `initSdk`, set timeout value. + +### Android AD_ID permission +**Issues:** #593, #562 +**Root cause:** Google Play requires explicit `AD_ID` permission declaration. +**Fix:** Add `` to app manifest. + +## TypeScript types (11 issues) + +### Types don't match runtime +**Issues:** #670, #575, #475, #194 +**Root cause:** `index.d.ts` is hand-maintained and drifts from actual native output. +**Fix:** Verify types against native output on both platforms. Use `patch-package` as user workaround. + +## RN version compatibility (13 issues) + +### podspecPath / config.js invalid +**Issues:** #458, #421, #403, #395 +**Root cause:** RN 0.68+ changed `react-native.config.js` schema. +**Fix:** Upgrade plugin to version matching RN version. + +### NativeEventEmitter warning +**Issues:** #335 +**Root cause:** RN 0.65+ requires `addListener`/`removeListeners` on native modules. +**Fix:** Upgrade to plugin version with stub methods. + +### Event callbacks silent with local path dependency (file:..) +**Issues:** SO#79083213, discovered during E2E 2026-05-12 +**Root cause:** When the plugin is referenced via `"file:.."` in `package.json` (local development), both the plugin root and the example app get their own `node_modules/react-native`. The plugin's `index.js` creates a `NativeEventEmitter` from its copy, while the app runtime uses the example's copy — two separate event bus instances. All event callbacks (`onDeepLink`, `onInstallConversionData`, `onAppOpenAttribution`) silently fail because listeners register on bus A while native emits on bus B. +**Fix:** In the example/demo app's `metro.config.js`, add `extraNodeModules` to force all `react-native` imports to resolve from the example's `node_modules`, and `blockList` to prevent Metro from resolving the parent's copy: +```js +extraNodeModules: { + 'react-native': path.resolve(__dirname, 'node_modules/react-native'), + react: path.resolve(__dirname, 'node_modules/react'), +}, +blockList: [ + new RegExp(path.resolve(pluginRoot, 'node_modules/react-native').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*'), + new RegExp(path.resolve(pluginRoot, 'node_modules/react').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*'), +], +``` +**Note:** This only affects local development. npm consumers have a single `react-native` instance and are unaffected. diff --git a/.claude/rules/native-android.md b/.claude/rules/native-android.md new file mode 100644 index 00000000..e0e64243 --- /dev/null +++ b/.claude/rules/native-android.md @@ -0,0 +1,68 @@ +--- +paths: + - "android/**" +--- + +# Native Android bridge rules + +Scope: `android/` directory — `RNAppsFlyerModule.java`, `RNAppsFlyerPackage.java`, `RNAppsFlyerConstants.java`, `RNUtil.java`. + +## 1. Module structure + +- `RNAppsFlyerModule extends ReactContextBaseJavaModule` — registered via `RNAppsFlyerPackage implements ReactPackage` +- Methods exposed with `@ReactMethod` annotation +- Method names match JS calls exactly (e.g., JS `initSdkWithCallBack` → Java `initSdkWithCallBack(ReadableMap, Callback, Callback)`) + +## 2. CallbackGuard pattern (critical) + +Added in 6.17.8 to fix double-invocation crashes (#601). Wraps every `Callback` with: +- `AtomicBoolean` to ensure single invocation +- `WeakReference` to handle bridge destruction gracefully + +```java +private static class CallbackGuard { + private final AtomicBoolean called = new AtomicBoolean(false); + private final WeakReference ref; + // invoke() checks-and-sets atomically +} +``` + +**Every new method that accepts a Callback must use CallbackGuard.** The React Native bridge crashes if a callback is invoked more than once — this is not optional. + +## 3. Constants export + +`getConstants()` exports `AFInAppEventType.*` constants to JS. These are available in JS as `RNAppsFlyer.ACHIEVEMENT_UNLOCKED`, etc. + +## 4. Event emission + +Uses `reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, data)`. Data is serialized to a JSON string before emission (matching iOS behavior). + +## 5. NativeEventEmitter stubs + +Lines ~1078-1085 in `RNAppsFlyerModule.java` have empty `addListener` and `removeListeners` method stubs annotated with `@ReactMethod`. These are required by RN's built-in `NativeEventEmitter` since RN 0.65. Do not remove them — their absence causes yellow-box warnings (#335). + +## 6. Purchase Connector conditional compilation + +Gradle `sourceSets` conditionally includes `includeConnector` or `excludeConnector` directory based on the `appsflyer.enable_purchase_connector` gradle property. This toggles whether `PCAppsFlyer` Java classes are compiled. + +## 7. Version constant + +`PLUGIN_VERSION` in `RNAppsFlyerConstants.java` — must be updated on every release, synchronized with the other 3 version locations. + +## 8. Namespace requirement (AGP 8+) + +`build.gradle` must include `namespace` for Android Gradle Plugin 8.0+. This was added in plugin 6.15.1. Older versions cause `Namespace not specified` build failures (#583, #561). + +## 9. Common Android build failures from issues + +| Symptom | Root cause | Fix | +|---------|-----------|-----| +| `Namespace not specified` (#583, #561) | AGP 8+ requires namespace in build.gradle | Upgrade plugin to 6.15.1+ | +| `Multiple entries: android:allowBackup=REPLACE` (#627) | AndroidManifest merge conflict | Add `tools:replace` in app's main manifest | +| `ConcurrentModificationException` (#447) | Thread safety in native SDK | Upgrade native SDK | +| `IllegalAccessException on logEvent` (#464) | Reflection issue in native SDK | Upgrade native SDK | +| `null is not an object (RNAppsFlyer.logEvent)` (#333) | Autolinking not triggered | Run Gradle sync, clear Metro cache | + +## 10. ReadableMap conversion + +`RNUtil.java` handles `ReadableMap` ↔ JSON conversion. When adding new methods that accept complex objects from JS, use `RNUtil` for conversion — do not write custom conversion logic. diff --git a/.claude/rules/native-ios.md b/.claude/rules/native-ios.md new file mode 100644 index 00000000..e41476ec --- /dev/null +++ b/.claude/rules/native-ios.md @@ -0,0 +1,65 @@ +--- +paths: + - "ios/**" +--- + +# Native iOS bridge rules + +Scope: `ios/` directory — `RNAppsFlyer.h`, `RNAppsFlyer.m`, `PCAppsFlyer.h`, `PCAppsFlyer.m`, `AppsFlyerAttribution.h/.m`. + +## 1. Module structure + +- `RNAppsFlyer` extends `RCTEventEmitter` (not `RCTBridgeModule` directly) — this enables `sendEventWithName:body:` +- Conforms to `AppsFlyerLibDelegate` and `AppsFlyerDeepLinkDelegate` +- Registered via `RCT_EXPORT_MODULE()` with no custom name + +## 2. Method export naming + +| JS call | ObjC selector | +|---------|--------------| +| `initSdkWithCallBack(options, success, error)` | `initSdkWithCallBack:successCallback:errorCallback:` | +| `initSdkWithPromise(options)` | `initSdkWithPromise:initSdkWithPromiseWithResolver:rejecter:` | +| `logEvent(name, values, success, error)` | `logEvent:eventValues:successCallback:errorCallback:` | +| `getAppsFlyerUID(callback)` | `getAppsFlyerUID:` | + +Follow the existing naming convention when adding new methods. Promise variants use `RCT_EXPORT_METHOD` with `resolver:(RCTPromiseResolveBlock)` and `rejecter:(RCTPromiseRejectBlock)`. + +## 3. Threading + +- Delegate callbacks use `performSelectorOnMainThread:withObject:waitUntilDone:NO` to dispatch to main thread before emitting JS events +- `logCrossPromotionAndOpenStore` uses `dispatch_async(dispatch_get_main_queue(), ...)` for UI operations +- All event emissions to JS must happen on the main thread + +## 4. IDFA strict mode + +`#ifndef AFSDK_NO_IDFA` guards ATT-related code. The podspec supports `$RNAppsFlyerStrictMode` which uses `AppsFlyerFramework/AppsFlyerFrameworkStrict` — this excludes IDFA access entirely. + +When adding ATT or IDFA-dependent code, always wrap in `#ifndef AFSDK_NO_IDFA`. + +## 5. Version constant + +`kAppsFlyerPluginVersion` in `RNAppsFlyer.h` — must be updated on every release. This is separate from the podspec version and package.json version (see release-versioning.md). + +## 6. Podspec dependency + +`react-native-appsflyer.podspec` pins the native SDK version via `s.dependency 'AppsFlyerFramework'`. Header-not-found errors (#633, #602, #646) are almost always caused by: +- Stale pod cache (fix: `pod deintegrate && pod install --repo-update`) +- Podfile.lock pinning a different native SDK version than the podspec expects +- Strict mode missing headers (`AppsFlyerFrameworkStrict` has different headers) + +## 7. Event names + +`supportedEvents` returns a fixed array. Adding a new event type requires: +1. Add to the `supportedEvents` array in `RNAppsFlyer.m` +2. Add matching event name constant on Android +3. Add listener registration method in `index.js` +4. Add type in `index.d.ts` + +## 8. Common iOS build failures from issues + +| Symptom | Root cause | Fix | +|---------|-----------|-----| +| `react_native_appsflyer-Swift.h not found` (#646) | Mixed Swift/ObjC without bridging header | Check Xcode build settings for Swift bridging | +| `AppsFlyerConsent.h not found` (#633) | Native SDK version mismatch | Match plugin version to compatible native SDK | +| `Redefinition of SUCCESS` (#497, #541) | Enum collision with other libs | Update to plugin version where enum was namespaced | +| `unsupported Swift architecture` (#656) | Release build architecture mismatch | Check `EXCLUDED_ARCHS` build settings | diff --git a/.claude/rules/release-versioning.md b/.claude/rules/release-versioning.md new file mode 100644 index 00000000..ccb6c861 --- /dev/null +++ b/.claude/rules/release-versioning.md @@ -0,0 +1,94 @@ +--- +paths: + - "package.json" + - "CHANGELOG.md" + - "*.podspec" +--- + +# Release and versioning rules + +Scope: version bumps, CHANGELOG.md, release branches, native SDK alignment. + +## 1. Version surface — 4 files must stay in sync + +| File | Field | Example | +|------|-------|---------| +| `package.json` | `"version"` | `"6.17.9"` | +| `react-native-appsflyer.podspec` | `s.version` | `'6.17.9'` | +| `ios/RNAppsFlyer.h` | `kAppsFlyerPluginVersion` | `@"6.17.9"` | +| `android/…/RNAppsFlyerConstants.java` | `PLUGIN_VERSION` | `"6.17.9"` | + +Missing any one of these causes version mismatch bugs. Historical commits that were solely version constant syncs: `45a0cfeb`, `20c80b46`, `0b19d154`. + +## 2. Version scheme + +Plugin version mirrors native SDK major.minor, with its own patch: +- `6.17.9` = plugin wrapping iOS SDK 6.17.8 + Android SDK 6.17.5 +- The plugin patch number is independent of native SDK patch numbers + +## 3. Semver rules + +| Change type | Bump | Examples | +|-------------|------|---------| +| Major | Never happened in 6.x era | Would require: removed public method, changed signature | +| Minor | New API additions | 6.9.1 (separated initSDK/startSDK), 6.13.0 (DMA support), 6.17.0 (Purchase Connector) | +| Patch | Native SDK updates, bug fixes, doc-only | 6.17.7 (SDK-only), 6.17.8 (bug fixes + new flags) | + +## 4. Deprecation pattern + +```js +// In index.js — runtime warning +console.warn('validateAndLogInAppPurchase is deprecated. Use AppsFlyerPurchaseConnector instead.'); + +// In index.d.ts — type annotation +/** @deprecated Use AppsFlyerPurchaseConnector instead */ +export function validateAndLogInAppPurchase(...): void; +``` + +Deprecated methods must continue to work at runtime. Add a backward-compat test in `__tests__/compatibility.test.js`. + +## 5. CHANGELOG format + +```markdown +## 6.17.9 + Release date: *2025-01-15* + +- React Native >> Description of change +- React Native >> Another change +``` + +Rules: +- Entries use `React Native >>` prefix +- Dates use ISO format (YYYY-MM-DD) +- Newest version at top (reverse chronological) +- Breaking changes get a separate "Breaking changes" subsection with before/after + +## 6. Branch naming + +| Type | Pattern | Example | +|------|---------|---------| +| Feature | `dev/DELIVERY-{ticket}/description` | `dev/DELIVERY-115184/update-6.17.9` | +| Release | `releases/6.x.x/6.{minor}.x/6.{minor}.{patch}-rc{N}` | `releases/6.x.x/6.17.x/6.17.9-rc3` | +| Hotfix | `{author}-patch-{N}` | `al-af-patch-1` | + +## 7. Tag convention + +Pre-6.x: tags use `v` prefix (`v1.2.0` through `v5.4.40`). The 6.x series has no tags — releases tracked via branches and CHANGELOG. + +## 8. Native SDK dependency update + +When updating the native SDK version: +1. Update `react-native-appsflyer.podspec` dependency version +2. Update `android/build.gradle` dependency version +3. Test that all existing bridge methods still compile against new headers +4. Check CHANGELOG of native SDK for breaking changes that affect the bridge +5. If native SDK added new APIs, decide whether to bridge them (minor bump if yes) + +## 9. Release checklist + +1. All 4 version constants updated and matching +2. CHANGELOG.md updated with new entry at top +3. `npm test` passes +4. `npx tsc --noEmit` passes +5. Manual test on iOS simulator and Android emulator +6. Demo app builds and runs on both platforms diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000..a744ea61 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,78 @@ +--- +paths: + - "__tests__/**" + - "jest.config.js" +--- + +# Testing conventions + +Scope: `__tests__/` directory, `jest.config.js`, test-related changes. + +## 1. Framework and config + +- Jest via `react-native` preset with `ts-jest` for TypeScript test support +- Config: `jest.config.js` +- Setup: `__tests__/setup.js` — mocks `NativeModules.RNAppsFlyer` (every native method is `jest.fn()`) and `NativeEventEmitter` +- Run: `npm test` (jest with coverage) + +## 2. Test files + +| File | Focus | +|------|-------| +| `__tests__/index.test.js` | Core API surface + event emitters (~80 tests) | +| `__tests__/compatibility.test.js` | Backward compat for consent, StoreKit, callbacks (~15 tests) | +| `__tests__/linting.test.js` | ESLint validation of source files (~6 tests) | +| `__tests__/purchase-connector.test.ts` | PurchaseConnector models + interface (~40 tests) | + +## 3. Test pattern: mock-and-verify + +All tests follow the same pattern: +1. Call the JS API method +2. Assert the correct **native method** was called with expected arguments +3. For event emitters: emit an event, assert the handler received correct data + +```js +// Example pattern +appsFlyer.logEvent('af_purchase', { af_revenue: 10 }, successCB, errorCB); +expect(RNAppsFlyer.logEvent).toHaveBeenCalledWith('af_purchase', { af_revenue: 10 }, successCB, errorCB); +``` + +No integration tests or native-level tests exist. All native modules are fully mocked. + +## 4. Event listener tests + +Test both paths: +- Happy path: native emits valid JSON string → handler receives parsed object +- Parse failure: native emits invalid JSON → handler receives `AFParseJSONException` object + +## 5. Compatibility tests + +`compatibility.test.js` verifies deprecated APIs still work at runtime. When deprecating a method, add a test here proving the old call signature still routes correctly. + +## 6. Linting-as-tests + +`linting.test.js` runs ESLint programmatically inside Jest. This is unusual but ensures lint rules are enforced in CI even without a separate lint step. + +## 7. Coverage gaps (known) + +These areas have **no test coverage** — adding tests here is high-value: +- Expo config plugins (`expo/withAppsFlyer.js`, `expo/withAppsFlyerIos.js`, `expo/withAppsFlyerAndroid.js`) +- Native-level unit tests (no XCTest, no Android JUnit) +- `logAdRevenue`, `logLocation`, `logCrossPromotionImpression`, `logCrossPromotionAndOpenStore` +- Edge cases in event listener cleanup (multiple listeners, unmount timing) + +## 8. What to test when adding a new method + +1. JS API calls correct native method name with correct arguments +2. Promise variant returns a Promise (not undefined) +3. Callback variant invokes the provided callbacks +4. Input validation (if any) rejects invalid types +5. Add backward-compat test if the method replaces a deprecated one + +## 9. Do not mock internals + +Tests should only mock `NativeModules` (via `setup.js`). Do not mock internal JS functions within `index.js` — test through the public API surface. + +## 10. Avoid tautological tests + +Some existing tests assert constants equal themselves (e.g., `expect('ironsource').toBe('ironsource')`). Do not add more of these — they test nothing. diff --git a/.claude/rules/typescript-types.md b/.claude/rules/typescript-types.md new file mode 100644 index 00000000..9e14e69a --- /dev/null +++ b/.claude/rules/typescript-types.md @@ -0,0 +1,56 @@ +--- +paths: + - "index.d.ts" + - "**/*.d.ts" +--- + +# TypeScript type definitions + +Scope: `index.d.ts` and any `.d.ts` files in the repo. + +## 1. Hand-maintained, not generated + +`index.d.ts` is manually maintained — not generated from source. It declares the module via `declare module "react-native-appsflyer"`. This means types can (and do) drift from actual runtime behavior. + +## 2. Chronic drift problem + +This is a recurring source of issues (#670, #575, #475, #218, #194): +- Types say one shape, native returns another +- `any` fallback types defeat TypeScript's purpose +- Deep link data shape differs between iOS and Android, but types assume a single shape + +**When changing any JS API or native return value, update `index.d.ts` in the same PR.** + +## 3. Type conventions + +```typescript +// Callback overload + Promise overload pattern +export function initSdk(options: InitSdkOptions, successC?: SuccessCB, errorC?: ErrorCB): Promise; + +// Event listener registration — returns cleanup function +export function onDeepLink(callback: (data: UnifiedDeepLinkData) => void): () => void; + +// Enum-like frozen objects +export const AFPurchaseType: { SUBSCRIPTION: string; ONE_TIME_PURCHASE: string }; +``` + +## 4. Rules for modifying types + +1. **Match runtime**: types must reflect what native actually returns, not what the docs say it should return. Test on both platforms before updating. +2. **No `any` for known shapes**: if the native return type is known, type it. Use `Record` for truly dynamic data, not `any`. +3. **Platform-conditional types**: where iOS and Android return different shapes, document both in JSDoc. Use union types if the difference is structural. +4. **Deprecation**: mark deprecated methods with `@deprecated` JSDoc tag. Keep the type signature for backward compatibility until removal. +5. **New exports**: every named export from `index.js` needs a matching type in `index.d.ts`. Missing types = broken TypeScript consumers. + +## 5. Stale header + +The file header says "Sync with v5.1.1" — this is misleading (last real sync was long ago). Do not rely on this header for version tracking. + +## 6. Validation approach + +After changing types: +```bash +npx tsc --noEmit # catches type errors in PurchaseConnector TS files +``` + +For `index.d.ts` specifically, manual review against the JS implementation is required — `tsc` doesn't validate `.d.ts` against `.js`. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..2f44d358 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Read(*)", + "Bash(npm test*)", + "Bash(npm run lint*)", + "Bash(npx tsc*)", + "Bash(npx jest*)", + "Bash(npx react-native*)", + "Bash(git log*)", + "Bash(git diff*)", + "Bash(git status*)", + "Bash(git branch*)", + "Bash(git show*)", + "Bash(wc *)", + "Bash(find *)", + "Bash(grep *)", + "Bash(ls *)", + "Bash(cat *)", + "Bash(gh issue*)", + "Bash(gh pr list*)", + "Bash(gh pr view*)" + ], + "ask": [ + "Bash(rm *)", + "Bash(git push*)", + "Bash(git reset*)", + "Bash(git checkout -- *)", + "Bash(npm publish*)", + "Bash(pod install*)", + "Bash(pod deintegrate*)" + ] + }, + "env": { + "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "80" + }, + "plansDirectory": "./docs/plans", + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "cd ${CLAUDE_PROJECT_DIR} && FILE=$(echo \"$TOOL_INPUT\" | grep -o '\"file_path\":\"[^\"]*\"' 2>/dev/null | head -1 | cut -d'\"' -f4); if [ -n \"$FILE\" ] && echo \"$FILE\" | grep -qE '\\.(js|ts|jsx|tsx)$'; then npx eslint --no-error-on-unmatched-pattern --quiet \"$FILE\" 2>/dev/null || true; fi", + "timeout": 10000 + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..6bcddfa6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,74 @@ +# appsflyer-react-native-plugin + +React Native bridge plugin wrapping the AppsFlyer iOS and Android native SDKs via `NativeModules`. + +## Architecture + +``` +index.js ← JS API surface (plain JS, no build step) +index.d.ts ← Hand-maintained TypeScript declarations +ios/RNAppsFlyer.m ← iOS bridge (RCTEventEmitter subclass) +android/…/RNAppsFlyerModule.java ← Android bridge (ReactContextBaseJavaModule) +expo/ ← Expo config plugin (withAppsFlyer*) +PurchaseConnector/ ← Optional purchase validation module (TS) +``` + +Two native modules per platform: `RNAppsFlyer` (core) and `PCAppsFlyer` (purchase connector). + +## Commands + +```bash +# Tests +npm test # Jest with coverage +npx jest --testPathPattern=index # Run specific test file + +# Lint +npm run lint # ESLint check +npm run lint:fix # ESLint autofix + +# TypeScript +npx tsc --noEmit # Type-check (no output) + +# iOS +cd demos/demo/ios && pod install --repo-update + +# Android +cd demos/demo/android && ./gradlew clean +``` + +## Version surface (all must stay in sync) + +| File | Field | +|------|-------| +| `package.json` | `version` | +| `react-native-appsflyer.podspec` | `s.version` | +| `ios/RNAppsFlyer.h` | `kAppsFlyerPluginVersion` | +| `android/…/RNAppsFlyerConstants.java` | `PLUGIN_VERSION` | + +## Critical constraints + +- `onDeepLink` listener must register **before** `initSdk` — not after, not in a late-mounting component +- `appId` is required on iOS (numeric Apple ID), optional on Android — use `Platform.select()` +- `index.js` is the published entry point with no transpilation — write ES module syntax compatible with Metro +- `index.d.ts` is hand-maintained and drifts from runtime behavior — verify against native output on both platforms +- Callbacks must fire exactly once across the bridge — Android uses `CallbackGuard` (AtomicBoolean + WeakReference) +- Native SDK delegate callbacks must dispatch to main thread before emitting to JS + +## Do not duplicate + +See `~/.claude/CLAUDE.md` for: ObjC/Swift conventions, security checklist, testing expectations, memory safety, threading patterns. Those apply here too. + +## Rules + +Domain-specific rules live in `.claude/rules/`: + +| File | Scope | +|------|-------| +| `bridge-patterns.md` | JS ↔ native bridge contract | +| `native-ios.md` | iOS bridge: ObjC, CocoaPods, RCTEventEmitter | +| `native-android.md` | Android bridge: Java module, Gradle, CallbackGuard | +| `testing.md` | Jest patterns, mocks, coverage gaps | +| `typescript-types.md` | index.d.ts conventions, public API surface | +| `expo-config.md` | Expo config plugin (withAppsFlyer*) | +| `known-issues-kb.md` | Issue-based KB with real GitHub issue references | +| `release-versioning.md` | Versioning, CHANGELOG, native SDK alignment | From f8dfe09f94e93fa8ae3e238876cc0ab3f51c6bf7 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 12 May 2026 15:11:12 +0300 Subject: [PATCH 03/21] Replace legacy workflows with RC pipeline and E2E CI --- .github/workflows/android-e2e.yml | 113 +++ .github/workflows/build-apps-workflow.yml | 54 -- .github/workflows/deploy-to-QA.yml | 61 -- .github/workflows/ios-e2e.yml | 172 ++++ .github/workflows/lint-test-build.yml | 128 +++ .github/workflows/pre-release-workflow.yml | 58 -- .github/workflows/production-release.yml | 505 +++++++++++ .github/workflows/promote-release.yml | 242 +++++ .github/workflows/rc-release.yml | 843 ++++++++++++++++++ .github/workflows/rc-smoke.yml | 485 ++++++++++ .../workflows/release-Production-workflow.yml | 80 -- .github/workflows/release-QA-workflow.yml | 19 - .github/workflows/scripts/archiveApp.sh | 10 - .github/workflows/scripts/decryptSecrets.sh | 19 - .../scripts/releaseNotesGenerator.sh | 19 - .github/workflows/scripts/updateReadme.sh | 6 - .../scripts/versionsAlignmentValidator.sh | 17 - .github/workflows/unit-tests-workflow.yml | 20 - 18 files changed, 2488 insertions(+), 363 deletions(-) create mode 100644 .github/workflows/android-e2e.yml delete mode 100644 .github/workflows/build-apps-workflow.yml delete mode 100644 .github/workflows/deploy-to-QA.yml create mode 100644 .github/workflows/ios-e2e.yml create mode 100644 .github/workflows/lint-test-build.yml delete mode 100644 .github/workflows/pre-release-workflow.yml create mode 100644 .github/workflows/production-release.yml create mode 100644 .github/workflows/promote-release.yml create mode 100644 .github/workflows/rc-release.yml create mode 100644 .github/workflows/rc-smoke.yml delete mode 100644 .github/workflows/release-Production-workflow.yml delete mode 100644 .github/workflows/release-QA-workflow.yml delete mode 100755 .github/workflows/scripts/archiveApp.sh delete mode 100755 .github/workflows/scripts/decryptSecrets.sh delete mode 100755 .github/workflows/scripts/releaseNotesGenerator.sh delete mode 100755 .github/workflows/scripts/updateReadme.sh delete mode 100755 .github/workflows/scripts/versionsAlignmentValidator.sh delete mode 100644 .github/workflows/unit-tests-workflow.yml diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml new file mode 100644 index 00000000..b9358cb8 --- /dev/null +++ b/.github/workflows/android-e2e.yml @@ -0,0 +1,113 @@ +# ============================================================================= +# Android E2E - integration tests (pre-publish) +# ============================================================================= +# +# Stage: RC-E2E in the RC release pipeline. +# +# Runs the .af-e2e/test-plan.json scenarios against the plugin source +# (example linked to the plugin). Drives the unified +# scripts/af-scenario-runner.sh on an Android emulator via +# reactivecircus/android-emulator-runner (KVM on ubuntu-latest). +# +# Triggers: +# - workflow_call from rc-release.yml (the gate before publish-rc) +# - workflow_dispatch for manual reruns +# - Weekly cron (Sunday 03:00 UTC) to catch SDK / RN drift +# +# Reports land in .af-e2e/reports/ and are uploaded as android-e2e- +# artifacts. +# ============================================================================= + +name: Android E2E + +on: + workflow_call: + inputs: + ref: + description: 'Branch, tag, or SHA to check out. Defaults to the calling workflow''s ref.' + required: false + type: string + default: '' + workflow_dispatch: + inputs: + ref: + description: 'Branch, tag, or SHA to check out (default: workflow ref)' + required: false + type: string + default: '' + schedule: + - cron: '0 3 * * 0' + +concurrency: + group: e2e-android-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-android: + name: E2E Tests (Android) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + # Some ubuntu-latest runner images ship /dev/kvm owned by group kvm + # without adding the runner user to that group. The udev rule below + # grants 0666 perms so android-emulator-runner uses -accel kvm. + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls -l /dev/kvm + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install example app dependencies + working-directory: example + run: npm ci + + - name: Write example app .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: printf '%s\n' "$ENV_FILE" > example/.env + + - name: Build and run E2E on Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 33 + arch: x86_64 + profile: pixel_6 + # -dns-server bypasses the host's systemd-resolved stub at 127.0.0.53, + # which the emulator's network namespace can't reach. Without this, + # every AppsFlyer SDK HTTP call fails with Unable to resolve host. + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 + disable-animations: true + script: | + set -e + adb shell 'getprop net.dns1; getprop net.dns2' || true + adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } + cd example/android && ./gradlew assembleDebug && cd ../../.. + ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json + + - name: Upload E2E reports + if: always() + uses: actions/upload-artifact@v5 + with: + name: android-e2e-${{ github.run_number }} + path: .af-e2e/reports/ + retention-days: 30 diff --git a/.github/workflows/build-apps-workflow.yml b/.github/workflows/build-apps-workflow.yml deleted file mode 100644 index 01eb0270..00000000 --- a/.github/workflows/build-apps-workflow.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Build apps with AppsFlyer plugin - -on: - workflow_call: - -jobs: - Build-RN-android: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Set up JDK 17 - uses: actions/setup-java@v2 - with: - java-version: '17' - distribution: 'adopt' - - - name: install react-native-appsflyer on an Android app - run: | - cd demos/appsflyer-react-native-app - yarn install - yarn add ../../ --save - - - name: Build apk - run: | - cd demos/appsflyer-react-native-app/android - chmod +x ./gradlew - ./gradlew assembleRelease - # Build-RN-ios: - # runs-on: macos-latest - # steps: - # - uses: actions/checkout@v3 - # - name: install react-native-appsflyer on an iOS app - # run: | - # cd demos/appsflyer-react-native-app - # yarn install --force - # yarn add ../../ --save - # - name: Install Dependencies - # run: | - # cd demos/appsflyer-react-native-app/ios - # pod install --repo-update - - # - name: Setup provisioning profile - # env: - # IOS_KEYS: ${{ secrets.IOS_KEYS }} - # run: | - # chmod +x .github/workflows/scripts/decryptSecrets.sh - # ./.github/workflows/scripts/decryptSecrets.sh - # - name: Archive app - # run: | - # sudo xcode-select --switch /Applications/Xcode_12.5.1.app - # chmod +x .github/workflows/scripts/archiveApp.sh - # cd demos/appsflyer-react-native-app/ios - # ./../../../.github/workflows/scripts/archiveApp.sh \ No newline at end of file diff --git a/.github/workflows/deploy-to-QA.yml b/.github/workflows/deploy-to-QA.yml deleted file mode 100644 index f4e1dd9a..00000000 --- a/.github/workflows/deploy-to-QA.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Deploy To QA - -on: - workflow_call: - -jobs: - Deploy-to-QA: - runs-on: ubuntu-latest - environment: Staging - steps: - - uses: actions/checkout@v3 - - name: Login to Github - env: - COMMIT_AUTHOR: ${{ secrets.CI_COMMIT_AUTHOR }} - COMMIT_EMAIL: ${{ secrets.CI_COMMIT_EMAIL }} - run: | - git config --global user.name $COMMIT_AUTHOR - git config --global user.email $COMMIT_EMAIL - - - name: Check if fixed version is on Jira - env: - JIRA_TOKEN: ${{ secrets.CI_JIRA_TOKEN }} - BRANCH_NAME: ${{github.ref_name}} - run: | - fixed_version_found=false - plugin_version=$(echo "$BRANCH_NAME" | grep -Eo '[0-9].[0-9]+.[0-9]+') - jira_fixed_version="React Native SDK v$plugin_version" - echo "JIRA_FIXED_VERSION=$jira_fixed_version" >> $GITHUB_ENV - chmod +x .github/workflows/scripts/releaseNotesGenerator.sh - .github/workflows/scripts/releaseNotesGenerator.sh $JIRA_TOKEN "$jira_fixed_version" - - - name: Check version alignment between platforms - env: - BRANCH_NAME: ${{github.ref_name}} - run: | - plugin_version=$(echo "$BRANCH_NAME" | grep -Eo '[0-9].[0-9]+.[0-9]+') - chmod +x .github/workflows/scripts/versionsAlignmentValidator.sh - .github/workflows/scripts/versionsAlignmentValidator.sh "$plugin_version" - - - name: Update package.json file - run: | - plugin_rc_version=$(echo "${{github.ref_name}}" | grep -Eo '[0-9].[0-9]+.[0-9]+-rc[0-9]+') - echo "Updating plugin to version $plugin_rc_version" - npm version $plugin_rc_version - git push - - - name: Push to NPM - env: - CI_NPM_TOKEN: ${{ secrets.CI_NPM_TOKEN }} - run: | - echo "//registry.npmjs.org/:_authToken=$CI_NPM_TOKEN" > ~/.npmrc - npm publish --tag QA - - - name: Generate and send slack report - env: - SLACK_TOKEN: ${{ secrets.CI_SLACK_TOKEN }} - run: | - ios_sdk_version=$(cat react-native-appsflyer.podspec | grep '\'AppsFlyerFramework\' | grep -Eo '[0-9].[0-9]+.[0-9]+') - android_sdk_version=$(cat android/build.gradle | grep 'com.appsflyer:af-android-sdk' | grep -Eo '[0-9].[0-9]+.[0-9]+') - CHANGES=$(cat "${{env.JIRA_FIXED_VERSION}}-releasenotes".txt) - curl -X POST -H 'Content-type: application/json' --data '{"jira_fixed_version": "'"${{env.JIRA_FIXED_VERSION}}"'", "deploy_type": "QA", "install_tag": "QA", "git_branch": "'"${{github.ref_name}}"'", "changes_and_fixes": "'"$CHANGES"'", "android_dependencie": "'"$android_sdk_version"'", "ios_dependencie": "'"$ios_sdk_version"'"}' "$SLACK_TOKEN" diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml new file mode 100644 index 00000000..4a466209 --- /dev/null +++ b/.github/workflows/ios-e2e.yml @@ -0,0 +1,172 @@ +# ============================================================================= +# iOS E2E - integration tests (pre-publish) +# ============================================================================= +# +# Stage: RC-E2E in the RC release pipeline. +# +# Runs the .af-e2e/test-plan.json scenarios against the plugin source +# (example linked to the plugin via path). Drives +# the unified scripts/af-scenario-runner.sh on an iOS simulator. +# +# Triggers: +# - workflow_call from rc-release.yml (the gate before publish-rc) +# - workflow_dispatch for manual reruns and ad-hoc validation +# - Weekly cron (Sunday 02:00 UTC) to catch SDK / RN drift +# +# Reports land in .af-e2e/reports/ and are uploaded as ios-e2e- artifacts. +# ============================================================================= + +name: iOS E2E + +on: + workflow_call: + inputs: + ref: + description: 'Branch, tag, or SHA to check out. Defaults to the calling workflow''s ref.' + required: false + type: string + default: '' + workflow_dispatch: + inputs: + ref: + description: 'Branch, tag, or SHA to check out (default: workflow ref)' + required: false + type: string + default: '' + schedule: + - cron: '0 2 * * 0' + +concurrency: + group: e2e-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e-ios: + name: E2E Tests (iOS) + runs-on: macos-15 + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Select Xcode version + run: | + XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) + sudo xcode-select -s "/Applications/$XCODE" + xcodebuild -version + + - name: Boot iOS simulator (newest iOS runtime) + run: | + # Pick a plain iPhone (15/16/17) from the newest available iOS 17/18 + # runtime. macos-15 ships iOS 18.x simulators; we filter to iOS 17/18 + # for deep-link phases. iOS 17.4+ and iOS 18.x deliver URLs straight + # to application:openURL:options: without a confirmation prompt. + DEVICES_JSON=$(xcrun simctl list -j devices available) + UDID=$(echo "$DEVICES_JSON" | jq -r ' + .devices + | to_entries + | map(select(.key | test("iOS-1[78]"))) + | sort_by(.key) | reverse + | map( + . as $rt + | $rt.value + | map(select(.name | test("^iPhone 1[5-7]$"))) + | first + | (if . then {udid, runtime: $rt.key, name} else empty end) + ) + | first // empty + | .udid // empty + ') + if [[ -z "$UDID" ]]; then + echo "No iPhone 15/16/17 on iOS 17/18 found; falling back to first available iPhone." + UDID=$(echo "$DEVICES_JSON" | jq -r '.devices[][] | select(.name | test("^iPhone")) | .udid' | head -1) + fi + [[ -z "$UDID" ]] && { echo "::error::No iOS simulator available"; exit 1; } + echo "Selected simulator UDID: $UDID" + echo "$DEVICES_JSON" | jq -r --arg u "$UDID" ' + .devices | to_entries[] | . as $rt + | $rt.value[] | select(.udid == $u) + | "Runtime: \($rt.key) Device: \(.name)" + ' + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + echo "IOS_SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" + + - name: Cache CocoaPods + uses: actions/cache@v5 + with: + path: example/ios/Pods + key: pods-${{ hashFiles('example/ios/Podfile.lock') }} + restore-keys: pods- + + - name: Install example app dependencies + working-directory: example + run: npm ci + + - name: Install CocoaPods + working-directory: example/ios + run: pod install + + - name: Write example app .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: printf '%s\n' "$ENV_FILE" > example/.env + + - name: Cache iOS build output + uses: actions/cache@v5 + with: + path: example/ios/build + key: ios-build-${{ hashFiles('ios/**', 'index.js', 'example/ios/**') }} + restore-keys: ios-build- + + - name: Build iOS simulator app (debug) + working-directory: example/ios + run: | + xcodebuild build \ + -workspace example.xcworkspace \ + -scheme example \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_UDID" \ + -derivedDataPath build \ + | xcpretty || true + + - name: Run E2E (af-scenario-runner against .af-e2e/test-plan.json) + run: ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json + + - name: Dump iOS QA log file (debug) + if: always() + run: | + SIM_DATA="$HOME/Library/Developer/CoreSimulator/Devices/$IOS_SIMULATOR_UDID/data" + LOG=$(find "$SIM_DATA/Containers/Data/Application" -name "af_qa_logs.txt" 2>/dev/null | head -1) + echo "=== QA log: ${LOG:-not found} ===" + cat "$LOG" 2>/dev/null | head -200 || echo "(empty)" + + - name: Dump simulator state and deep-link events (debug) + if: failure() + run: | + echo "=== Booted simulators ===" + xcrun simctl list devices booted || true + echo "=== Installed apps for $IOS_SIMULATOR_UDID ===" + xcrun simctl listapps "$IOS_SIMULATOR_UDID" 2>/dev/null \ + | grep -E "com.appsflyer|CFBundleURLSchemes|example" || true + echo "=== Last 90s of simctl URL/launchservices events ===" + xcrun simctl spawn "$IOS_SIMULATOR_UDID" log show \ + --last 90s --style compact 2>/dev/null \ + | grep -E "Opening URL|openURL|launchservices|com.appsflyer|FrontBoard:Process" \ + | tail -200 || true + + - name: Upload E2E reports + if: always() + uses: actions/upload-artifact@v5 + with: + name: ios-e2e-${{ github.run_number }} + path: .af-e2e/reports/ + retention-days: 30 diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml new file mode 100644 index 00000000..4d2e5b84 --- /dev/null +++ b/.github/workflows/lint-test-build.yml @@ -0,0 +1,128 @@ +# ============================================================================= +# Lint, Test & Build - PR/push gate and reusable validation workflow +# ============================================================================= +# +# Purpose: Validates code quality, runs unit tests, and builds the demo app +# in release mode for both Android and iOS on every PR and push to +# development/master. Also reusable from rc-release.yml. +# +# What it does: +# 1. Lints + runs Jest unit tests with coverage. +# 2. Builds Android demo app (release APK). +# 3. Builds iOS demo app (no-codesign release build). +# +# Triggers: +# - Pull requests to development or master branches +# - Direct pushes to development or master branches +# - Manual workflow dispatch for testing +# - workflow_call (rc-release.yml) +# +# ============================================================================= + +name: Lint, Test & Build + +on: + pull_request: + branches: [development, master] + paths-ignore: ['**.md', 'Docs/**', 'demos/**'] + push: + branches: [development, master] + paths-ignore: ['**.md', 'Docs/**', 'demos/**'] + workflow_dispatch: + workflow_call: + inputs: + skip_unit: + description: 'Skip unit tests and linting' + required: false + type: boolean + default: false + skip_builds: + description: 'Skip Android + iOS release builds' + required: false + type: boolean + default: false + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Jest + ESLint + runs-on: ubuntu-latest + if: ${{ inputs.skip_unit != true }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm run lint + - run: npm test -- --coverage + + build-android: + name: Build Android (release) + runs-on: ubuntu-latest + needs: test + if: ${{ (needs.test.result == 'success' || needs.test.result == 'skipped') && inputs.skip_builds != true }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - name: Install example app dependencies + working-directory: example + run: npm ci + - name: Build Android release APK + working-directory: example/android + run: ./gradlew assembleRelease + + build-ios: + name: Build iOS (release) + runs-on: macos-15 + needs: test + if: ${{ (needs.test.result == 'success' || needs.test.result == 'skipped') && inputs.skip_builds != true }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: Install example app dependencies + working-directory: example + run: npm ci + - name: Install CocoaPods + working-directory: example/ios + run: pod install + - name: Build iOS (no codesign) + working-directory: example/ios + run: | + xcodebuild build \ + -workspace example.xcworkspace \ + -scheme example \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ + | xcpretty || true + + ci-summary: + name: CI Summary + runs-on: ubuntu-latest + needs: [test, build-android, build-ios] + if: always() + steps: + - name: Check results + run: | + for result in "${{ needs.test.result }}" "${{ needs.build-android.result }}" "${{ needs.build-ios.result }}"; do + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "CI failed"; exit 1 + fi + done + echo "CI passed" diff --git a/.github/workflows/pre-release-workflow.yml b/.github/workflows/pre-release-workflow.yml deleted file mode 100644 index ea444620..00000000 --- a/.github/workflows/pre-release-workflow.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Prepare plugin for production - -on: - pull_request: - types: - - opened - branches: - - 'master' - -jobs: - Prepare-Plugin-For-Production: - if: startsWith(github.head_ref, 'releases/') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Login to Github - env: - COMMIT_AUTHOR: ${{ secrets.CI_COMMIT_AUTHOR }} - COMMIT_EMAIL: ${{ secrets.CI_COMMIT_EMAIL }} - run: | - git config --global user.name $COMMIT_AUTHOR - git config --global user.email $COMMIT_EMAIL - - - uses: mdecoleman/pr-branch-name@1.2.0 - id: vars - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Update package.json file - run: | - plugin_version=$(echo "${{ steps.vars.outputs.branch }}" | grep -Eo '[0-9].[0-9]+.[0-9]+') - # we export plugin_version and release branch name as env so we can use them in the next step - echo "PLUGIN_VERSION=$plugin_version" >> $GITHUB_ENV - echo "GIT_BRANCH_RELEASE=${{ steps.vars.outputs.branch }}" >> $GITHUB_ENV - echo "Updating plugin to version $plugin_version" - npm version $plugin_version - git push origin HEAD:${{ steps.vars.outputs.branch }} --force - - - name: Update CHANGELOG.md - env: - JIRA_TOKEN: ${{ secrets.CI_JIRA_TOKEN }} - JIRA_FIXED_VERSION: "React Native SDK v${{env.PLUGIN_VERSION}}" - run: | - chmod +x .github/workflows/scripts/releaseNotesGenerator.sh - .github/workflows/scripts/releaseNotesGenerator.sh $JIRA_TOKEN "$JIRA_FIXED_VERSION" - NEW_VERSION_RELEASE_NOTES=$(cat "$JIRA_FIXED_VERSION-releasenotes".txt) - NEW_VERSION_SECTION="## ${{ env.PLUGIN_VERSION }}\n Release date: *$(date +%F)*\n\n$NEW_VERSION_RELEASE_NOTES\n" - echo -e "$NEW_VERSION_SECTION\n$(cat CHANGELOG.md)" > CHANGELOG.md - git add CHANGELOG.md - git commit -m "Update CHANGELOG.md" - git push origin HEAD:${{ env.GIT_BRANCH_RELEASE }} --force - - - name: Update README.md - run: | - chmod +x .github/workflows/scripts/updateReadme.sh - .github/workflows/scripts/updateReadme.sh - git add README.md - git commit -m "Update README.md" - git push origin HEAD:${{ env.GIT_BRANCH_RELEASE }} --force diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml new file mode 100644 index 00000000..90c7d108 --- /dev/null +++ b/.github/workflows/production-release.yml @@ -0,0 +1,505 @@ +# ============================================================================= +# Production Release Workflow - Publish to npm +# ============================================================================= +# +# Purpose: Publishes the React Native plugin to npm after a release PR is +# merged to master. +# +# Flow: +# 1. Validates the merge is from a release branch +# 2. Publishes to npm with the `latest` tag +# 3. Verifies the package is live on npm (retry loop, max 120s) +# 4. Creates a GitHub release with release notes +# 5. Fetches Jira tickets for the fix version +# 6. Notifies team via Slack +# +# Triggers: +# - Pull request closed (merged) to master branch from releases/* branches +# - Manual workflow dispatch (for republishing or testing) +# +# ============================================================================= + +name: Production Release - Publish to npm + +on: + pull_request: + types: + - closed + branches: + - master + + workflow_dispatch: + inputs: + version: + description: 'Version to release (must match package.json)' + required: true + type: string + dry_run: + description: 'Dry run (do not actually publish)' + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + # =========================================================================== + # Job 1: Validate Release + # =========================================================================== + validate-release: + name: Validate Release + runs-on: ubuntu-latest + + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'releases/')) + + outputs: + version: ${{ steps.get-version.outputs.version }} + is_valid: ${{ steps.validate.outputs.is_valid }} + is_dry_run: ${{ steps.dry-run.outputs.value }} + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Resolve dry_run across trigger paths + id: dry-run + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_DRY: ${{ github.event.inputs.dry_run }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$DISPATCH_DRY" == "true" ]]; then + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "value=false" >> "$GITHUB_OUTPUT" + fi + + - name: Validate release source + id: validate + env: + EVENT_NAME: ${{ github.event_name }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + echo "Manual run - skipping branch validation" + echo "is_valid=true" >> "$GITHUB_OUTPUT" + else + SOURCE_BRANCH="$PR_HEAD_REF" + echo "Source branch: $SOURCE_BRANCH" + + if [[ $SOURCE_BRANCH =~ ^releases/ ]]; then + echo "Valid release branch: $SOURCE_BRANCH" + echo "is_valid=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Not a release branch: $SOURCE_BRANCH" + echo "is_valid=false" >> "$GITHUB_OUTPUT" + exit 1 + fi + fi + + - name: Get version from package.json + id: get-version + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + VERSION="$INPUT_VERSION" + echo "Using provided version: $VERSION" + else + VERSION=$(node -p "require('./package.json').version") + echo "Extracted version from package.json: $VERSION" + fi + + # Validate version format (X.Y.Z, no -rc suffix for production) + if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Valid production version format: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "::error::Invalid production version format: $VERSION (expected X.Y.Z)" + exit 1 + fi + + - name: Check if tag already exists + env: + VERSION: ${{ steps.get-version.outputs.version }} + DRY_RUN: ${{ steps.dry-run.outputs.value }} + run: | + git fetch --tags + if git rev-parse "v$VERSION" >/dev/null 2>&1 || git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "::warning::Tag for $VERSION already exists" + if [[ "$DRY_RUN" != "true" ]]; then + echo "::error::Cannot create duplicate release" + exit 1 + fi + fi + echo "Tag $VERSION does not exist - safe to proceed" + + # =========================================================================== + # Job 2: Publish to npm + # =========================================================================== + publish-to-npm: + name: Publish to npm + runs-on: ubuntu-latest + needs: [validate-release] + if: always() && needs.validate-release.outputs.is_valid == 'true' + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Publish to npm + if: needs.validate-release.outputs.is_dry_run != 'true' + env: + CI_NPM_TOKEN: ${{ secrets.CI_NPM_TOKEN }} + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + echo "Publishing version $VERSION to npm..." + echo "//registry.npmjs.org/:_authToken=$CI_NPM_TOKEN" > ~/.npmrc + npm publish + rm ~/.npmrc + echo "Published $VERSION to npm" + + - name: Dry-run publish + if: needs.validate-release.outputs.is_dry_run == 'true' + run: | + echo "DRY RUN - would publish $(node -p "require('./package.json').version") to npm" + npm pack --dry-run + + - name: Verify publication + if: needs.validate-release.outputs.is_dry_run != 'true' + env: + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + MAX_WAIT=120 + POLL=15 + ELAPSED=0 + while (( ELAPSED < MAX_WAIT )); do + if npm view "react-native-appsflyer@$VERSION" version 2>/dev/null | grep -Fxq "$VERSION"; then + echo "Verified: $VERSION is live on npm" + break + fi + echo "Waiting for npm propagation (${ELAPSED}s / ${MAX_WAIT}s)..." + sleep "$POLL" + ELAPSED=$(( ELAPSED + POLL )) + done + if (( ELAPSED >= MAX_WAIT )); then + echo "::warning::npm propagation exceeded ${MAX_WAIT}s --- verify manually on npmjs.com" + fi + + # =========================================================================== + # Job 3: Create GitHub Release + # =========================================================================== + create-github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [validate-release, publish-to-npm] + if: always() && needs.validate-release.outputs.is_valid == 'true' && needs.validate-release.outputs.is_dry_run != 'true' + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Extract release notes from CHANGELOG + id: changelog + env: + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + echo "Extracting release notes for version $VERSION from CHANGELOG.md" + + if [ -f "CHANGELOG.md" ]; then + RELEASE_NOTES=$(awk "/## $VERSION/,/^## [0-9]/" CHANGELOG.md | sed '1d;$d') + + if [ -z "$RELEASE_NOTES" ]; then + echo "::warning::Could not find release notes for $VERSION in CHANGELOG.md" + RELEASE_NOTES="Release version $VERSION. See [CHANGELOG.md](CHANGELOG.md) for details." + fi + else + RELEASE_NOTES="Release version $VERSION." + fi + + echo "$RELEASE_NOTES" > release_notes.md + + - name: Build release notes + env: + VERSION: ${{ needs.validate-release.outputs.version }} + REPO: ${{ github.repository }} + run: | + cat > final_release_notes.md << EOF + # AppsFlyer React Native Plugin v$VERSION + + ## Installation + + \`\`\`bash + npm install react-native-appsflyer@$VERSION + \`\`\` + + Then for iOS: + \`\`\`bash + cd ios && pod install + \`\`\` + + ## Changes in This Release + + $(cat release_notes.md) + + ## Documentation + + - [Installation Guide](https://github.com/$REPO/blob/master/Docs/Installation.md) + - [API Documentation](https://github.com/$REPO/blob/master/Docs/API.md) + - [Deep Linking Guide](https://github.com/$REPO/blob/master/Docs/DeepLink.md) + - [Expo Integration](https://github.com/$REPO/blob/master/Docs/ExpoIntegration.md) + + ## Links + + - [npm Package](https://www.npmjs.com/package/react-native-appsflyer/v/$VERSION) + - [GitHub Repository](https://github.com/$REPO) + - [AppsFlyer Developer Hub](https://dev.appsflyer.com/) + + ## Support + + For issues and questions, please contact + EOF + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + gh release create "v$VERSION" \ + --title "v$VERSION" \ + --notes-file final_release_notes.md \ + --latest + + # =========================================================================== + # Job 4: Notify Team + # =========================================================================== + notify-team: + name: Notify Team + runs-on: ubuntu-latest + needs: [validate-release, publish-to-npm, create-github-release] + if: >- + always() && + needs.validate-release.outputs.is_dry_run != 'true' && + ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'releases/')) + ) + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Extract SDK versions and changelog + id: extract-info + env: + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + # Extract Android SDK fallback version from build.gradle + ANDROID_SDK_VERSION=$(grep -oP "af-android-sdk:\K[^')]*" android/build.gradle | head -1) + echo "android_sdk=$ANDROID_SDK_VERSION" >> "$GITHUB_OUTPUT" + + # Extract iOS SDK version from podspec + IOS_SDK_VERSION=$(grep "AppsFlyerFramework'" react-native-appsflyer.podspec | grep -oP "'~> \K[^']*" | head -1) + if [ -z "$IOS_SDK_VERSION" ]; then + IOS_SDK_VERSION=$(grep "AppsFlyerFramework'" react-native-appsflyer.podspec | grep -oP "'\K[0-9][^']*" | head -1) + fi + echo "ios_sdk=$IOS_SDK_VERSION" >> "$GITHUB_OUTPUT" + + # Extract changelog for this version + if [ -f "CHANGELOG.md" ]; then + CHANGELOG=$(awk "/## $VERSION/,/^## [0-9]/" CHANGELOG.md | grep "^-" | sed 's/^- /- /' | head -5) + if [ -z "$CHANGELOG" ]; then + CHANGELOG="- Check CHANGELOG.md for details" + fi + else + CHANGELOG="- Check release notes for details" + fi + + echo "changelog<> "$GITHUB_OUTPUT" + echo "$CHANGELOG" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Fetch Jira tickets + id: jira-tickets + continue-on-error: true + env: + VERSION: ${{ needs.validate-release.outputs.version }} + CI_JIRA_EMAIL: ${{ secrets.CI_JIRA_EMAIL }} + CI_JIRA_TOKEN: ${{ secrets.CI_JIRA_TOKEN }} + CI_JIRA_DOMAIN: ${{ secrets.CI_JIRA_DOMAIN }} + run: | + set +e + JIRA_FIX_VERSION="React Native SDK v$VERSION" + + echo "Looking for Jira tickets with fix version: $JIRA_FIX_VERSION" + + if [[ -z "$CI_JIRA_EMAIL" ]] || [[ -z "$CI_JIRA_TOKEN" ]]; then + echo "::warning::Jira credentials not configured" + echo "tickets=No assigned fix version found" >> "$GITHUB_OUTPUT" + exit 0 + fi + + JIRA_DOMAIN="${CI_JIRA_DOMAIN:-appsflyer.atlassian.net}" + + JQL_QUERY="fixVersion=\"${JIRA_FIX_VERSION}\"" + ENCODED_JQL=$(echo "$JQL_QUERY" | jq -sRr @uri) + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -u "$CI_JIRA_EMAIL:$CI_JIRA_TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + "https://${JIRA_DOMAIN}/rest/api/3/search/jql?jql=${ENCODED_JQL}&fields=key,summary&maxResults=20") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [[ "$HTTP_CODE" != "200" ]]; then + echo "::warning::Jira API request failed with status $HTTP_CODE" + echo "tickets=No assigned fix version found" >> "$GITHUB_OUTPUT" + exit 0 + fi + + TICKETS=$(echo "$BODY" | jq -r '.issues[]? | "- https://'"${JIRA_DOMAIN}"'/browse/\(.key) - \(.fields.summary)"' 2>/dev/null | head -10) + + if [ -z "$TICKETS" ]; then + echo "No linked tickets found for version: $JIRA_FIX_VERSION" + echo "tickets=No assigned fix version found" >> "$GITHUB_OUTPUT" + else + echo "Found Jira tickets:" + echo "$TICKETS" + echo "tickets<> "$GITHUB_OUTPUT" + echo "$TICKETS" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + fi + + - name: Determine status and failed stage + id: status + env: + VALIDATE_RESULT: ${{ needs.validate-release.result }} + PUBLISH_RESULT: ${{ needs.publish-to-npm.result }} + RELEASE_RESULT: ${{ needs.create-github-release.result }} + run: | + set -euo pipefail + if [[ "$VALIDATE_RESULT" == "success" \ + && "$PUBLISH_RESULT" == "success" \ + && "$RELEASE_RESULT" == "success" ]]; then + echo "success=true" >> "$GITHUB_OUTPUT" + echo "failed_stage=" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "success=false" >> "$GITHUB_OUTPUT" + if [[ "$VALIDATE_RESULT" != "success" ]]; then + echo "failed_stage=validate-release" >> "$GITHUB_OUTPUT" + elif [[ "$PUBLISH_RESULT" != "success" ]]; then + echo "failed_stage=publish-to-npm" >> "$GITHUB_OUTPUT" + else + echo "failed_stage=create-github-release" >> "$GITHUB_OUTPUT" + fi + + - name: Send Slack success notification + if: steps.status.outputs.success == 'true' + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "\n:react::react::react::react::react::react::react::react::react::react::react::react:\n\n*React Native:*\nnpm install react-native-appsflyer@${{ needs.validate-release.outputs.version }} is published to Production.\n\n:white_check_mark: rc-smoke/npm passed before promotion (verified by promote-release.yml).\n\n*Sources:*\n:github: https://github.com/${{ github.repository }}/tree/master\n:github: Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n:npm: https://www.npmjs.com/package/react-native-appsflyer/v/${{ needs.validate-release.outputs.version }}\n\n*Changes and fixes:*\n${{ steps.extract-info.outputs.changelog }}\n\n*Linked tickets and issues:*\n${{ steps.jira-tickets.outputs.tickets }}\n\n*Native SDKs:*\n:android: ${{ steps.extract-info.outputs.android_sdk }}\n:apple: ${{ steps.extract-info.outputs.ios_sdk }}\n\n:react::react::react::react::react::react::react::react::react::react::react::react:" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + + - name: Send Slack failure notification + if: steps.status.outputs.success == 'false' + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "\n:warning: *React Native production release failed at `${{ steps.status.outputs.failed_stage }}`*\n\nVersion: ${{ needs.validate-release.outputs.version }}\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Stage results:*\n- validate-release: ${{ needs.validate-release.result }}\n- publish-to-npm: ${{ needs.publish-to-npm.result }}\n- create-github-release: ${{ needs.create-github-release.result }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + + # =========================================================================== + # Job 5: Production Release Summary + # =========================================================================== + release-summary: + name: Release Summary + runs-on: ubuntu-latest + needs: [validate-release, publish-to-npm, create-github-release] + if: >- + always() && ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'releases/')) + ) + + steps: + - name: Display Release Summary + env: + VERSION: ${{ needs.validate-release.outputs.version }} + DRY_RUN: ${{ needs.validate-release.outputs.is_dry_run }} + VALIDATE_RESULT: ${{ needs.validate-release.result }} + PUBLISH_RESULT: ${{ needs.publish-to-npm.result }} + RELEASE_RESULT: ${{ needs.create-github-release.result }} + REPO: ${{ github.repository }} + run: | + echo "=========================================" + echo "Production Release Summary" + echo "=========================================" + echo "Version: $VERSION" + echo "Dry Run: $DRY_RUN" + echo "-----------------------------------------" + echo "Validation: $VALIDATE_RESULT" + echo "npm Publish: $PUBLISH_RESULT" + echo "GitHub Release: $RELEASE_RESULT" + echo "=========================================" + + if [[ "$DRY_RUN" == "true" ]]; then + echo "This was a DRY RUN - no actual publishing occurred" + exit 0 + fi + + if [[ "$VALIDATE_RESULT" == "success" ]] && \ + [[ "$PUBLISH_RESULT" == "success" ]] && \ + [[ "$RELEASE_RESULT" == "success" ]]; then + echo "" + echo "Production Release Completed Successfully!" + echo "" + echo "Version $VERSION is now live!" + echo "" + echo "npm: https://www.npmjs.com/package/react-native-appsflyer/v/$VERSION" + echo "GitHub: https://github.com/$REPO/releases/tag/v$VERSION" + else + echo "" + echo "Production Release Failed" + echo "Check the logs above for details and retry if necessary" + exit 1 + fi diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml new file mode 100644 index 00000000..649ebbe8 --- /dev/null +++ b/.github/workflows/promote-release.yml @@ -0,0 +1,242 @@ +# ============================================================================= +# Promote Release - Prepare Release Branch for Production +# ============================================================================= +# +# Purpose: When QA approves an RC, this workflow prepares the release branch +# for production by removing the -rc suffix from version numbers. +# +# IMPORTANT: This workflow does NOT merge the PR (org rules prevent bot merges). +# Instead, it updates the release branch so when a human merges, the version +# is clean (e.g., 6.18.0 instead of 6.18.0-rc1). +# +# Flow: +# 1. QA tests the RC version +# 2. QA adds label "pass QA ready for deploy" to the PR +# 3. This workflow triggers and: +# - Verifies rc-smoke/npm check-run is green +# - Strips -rcN from all version files +# - Commits changes to the release branch +# 4. Human reviews and manually merges the PR +# 5. production-release.yml triggers on merge +# +# ============================================================================= + +name: Promote Release - Prepare for Production + +on: + pull_request: + types: [labeled] + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + # =========================================================================== + # Job 1: Prepare Release Branch for Production + # =========================================================================== + prepare-for-production: + name: Prepare Release for Production + if: | + github.event.label.name == 'pass QA ready for deploy' && + startsWith(github.event.pull_request.head.ref, 'releases/') + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.compute-version.outputs.version }} + release_branch: ${{ steps.compute-version.outputs.release_branch }} + + steps: + - name: Verify rc-smoke/npm check-run is green + uses: actions/github-script@v7 + with: + script: | + const sha = context.payload.pull_request.head.sha; + const checkName = 'rc-smoke/npm'; + + const { data } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: sha, + check_name: checkName + }); + + const runs = (data.check_runs || []).filter(r => r.status === 'completed'); + runs.sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at)); + const latest = runs[0]; + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/workflows/rc-smoke.yml`; + const prNumber = context.payload.pull_request.number; + + if (!latest) { + const body = `**Promote blocked.** No \`${checkName}\` check-run exists on ${sha}.\n\n` + + `Run [rc-smoke.yml](${runUrl}) manually with the RC version, or wait for the auto-trigger from the RC-release workflow, then re-apply the label.`; + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body }); + core.setFailed(`${checkName} is missing on ${sha}`); + return; + } + if (latest.conclusion !== 'success') { + const body = `**Promote blocked.** \`${checkName}\` on ${sha} concluded \`${latest.conclusion}\`.\n\n` + + `Details: ${latest.details_url}\n\n` + + `If the RC is genuinely broken on npm, bump to \`rcN+1\` and rerun the RC-release workflow. A skipped check-run is not sufficient for promotion --- publish the RC for real (\`dry_run=false\`), then re-apply the label.`; + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body }); + core.setFailed(`${checkName} conclusion is ${latest.conclusion} on ${sha}`); + return; + } + + core.info(`${checkName} is success on ${sha}; proceeding with promotion.`); + + - name: Checkout release branch + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Compute production version + id: compute-version + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + RELEASE_BRANCH="$PR_HEAD_REF" + echo "Release branch: $RELEASE_BRANCH" + + CURRENT_VERSION=$(node -p "require('./package.json').version") + echo "Current version: $CURRENT_VERSION" + + PROD_VERSION=$(echo "$CURRENT_VERSION" | sed 's/-rc[0-9]*$//') + echo "Production version: $PROD_VERSION" + + if [[ "$CURRENT_VERSION" == "$PROD_VERSION" ]]; then + echo "::warning::Version doesn't have -rc suffix. Already production ready?" + echo "Current: $CURRENT_VERSION" + fi + + echo "version=$PROD_VERSION" >> "$GITHUB_OUTPUT" + echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" + echo "release_branch=$RELEASE_BRANCH" >> "$GITHUB_OUTPUT" + + - name: Strip -rcN from all version files + env: + VERSION: ${{ steps.compute-version.outputs.version }} + run: | + set -euo pipefail + echo "Updating all version files to production version: $VERSION" + + # 1. package.json (also updates react-native-appsflyer.podspec + # since podspec reads s.version from package.json) + npm version "$VERSION" --no-git-tag-version + echo "package.json:" && node -p "require('./package.json').version" + + # 2. ios/RNAppsFlyer.h — kAppsFlyerPluginVersion + IOS_FILE="ios/RNAppsFlyer.h" + if [ -f "$IOS_FILE" ]; then + sed -i "s/kAppsFlyerPluginVersion[[:space:]]*= @\"[^\"]*\"/kAppsFlyerPluginVersion = @\"$VERSION\"/" "$IOS_FILE" + echo "iOS:" && grep "kAppsFlyerPluginVersion" "$IOS_FILE" + fi + + # 3. android/.../RNAppsFlyerConstants.java — PLUGIN_VERSION + ANDROID_FILE="android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java" + if [ -f "$ANDROID_FILE" ]; then + sed -i "s/PLUGIN_VERSION = \"[^\"]*\"/PLUGIN_VERSION = \"$VERSION\"/" "$ANDROID_FILE" + echo "Android:" && grep "PLUGIN_VERSION" "$ANDROID_FILE" + fi + + - name: Commit and push version changes + env: + VERSION: ${{ steps.compute-version.outputs.version }} + CURRENT_VERSION: ${{ steps.compute-version.outputs.current_version }} + CI_COMMIT_AUTHOR: ${{ secrets.CI_COMMIT_AUTHOR }} + CI_COMMIT_EMAIL: ${{ secrets.CI_COMMIT_EMAIL }} + run: | + git config user.name "${CI_COMMIT_AUTHOR:-github-actions[bot]}" + git config user.email "${CI_COMMIT_EMAIL:-github-actions[bot]@users.noreply.github.com}" + + if [[ -n $(git status -s) ]]; then + git add package.json package-lock.json ios/RNAppsFlyer.h android/ + git commit -m "chore: prepare production release $VERSION (from $CURRENT_VERSION)" + git push + echo "Pushed version update to release branch" + else + echo "No version changes needed" + fi + + - name: Update PR description + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.compute-version.outputs.version }}'; + const currentVersion = '${{ steps.compute-version.outputs.current_version }}'; + const pr = context.payload.pull_request; + + const newBody = `### Production Release ${version}\n\n` + + `**Status:** Ready for manual merge\n\n` + + `**Version updated:** ${currentVersion} -> ${version}\n\n` + + `---\n\n` + + `${pr.body || ''}\n\n` + + `---\n\n` + + `**Next steps:**\n` + + `1. QA approved (label added)\n` + + `2. Version updated to production (${version})\n` + + `3. **Awaiting manual merge** by a maintainer\n` + + `4. Production release will trigger automatically after merge\n`; + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + body: newBody + }); + + - name: Add comment to PR + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.compute-version.outputs.version }}'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: `## Ready for Production Release\n\n` + + `The release branch has been updated:\n` + + `- **Version:** \`${version}\` (removed -rc suffix)\n` + + `- **All version files updated** (package.json, RNAppsFlyer.h, RNAppsFlyerConstants.java)\n\n` + + `### Next Steps\n` + + `1. **Review the changes** in this PR\n` + + `2. **Merge this PR** when ready\n` + + `3. The production release workflow will automatically:\n` + + ` - Publish \`${version}\` to npm\n` + + ` - Create GitHub release\n` + + ` - Send notifications\n\n` + + `> **Note:** This PR requires manual merge due to branch protection rules.` + }); + + # =========================================================================== + # Job 2: Notify Promote Failure + # =========================================================================== + notify-failure: + name: Notify Promote Failure + needs: prepare-for-production + runs-on: ubuntu-latest + if: always() && needs.prepare-for-production.result == 'failure' + + steps: + - name: Send Slack failure notification + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "\n:warning: *React Native promote-release blocked*\n\nPR: ${{ github.event.pull_request.html_url }}\nBranch: ${{ github.event.pull_request.head.ref }}\n\nThe promote workflow could not prepare the release branch for production. Common causes:\n- `rc-smoke/npm` is missing or red on the PR head SHA --- bump to `rcN+1` and rerun rc-release.\n- The version-strip push was rejected (branch protection or stale ref).\n\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + continue-on-error: true diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml new file mode 100644 index 00000000..c867b313 --- /dev/null +++ b/.github/workflows/rc-release.yml @@ -0,0 +1,843 @@ +# ============================================================================= +# RC (Release Candidate) Workflow - Pre-Production Release +# ============================================================================= +# +# Purpose: Creates a release candidate for QA testing before production release. +# +# What it does: +# 1. Validates the RC version format and inputs +# 2. Runs full CI pipeline (Jest + ESLint + release builds) +# 3. Creates a release branch with version bumps across 5+ files +# 4. Runs iOS + Android E2E tests on the release branch +# 5. Validates Jira fix version exists +# 6. Publishes to npm with --tag rc +# 7. Creates GitHub pre-release + PR to master +# 8. Notifies team via Slack +# +# Version format: X.Y.Z-rcN (e.g., 6.18.0-rc1) +# +# Triggers: +# - Manual workflow dispatch with required parameters +# +# ============================================================================= + +name: RC - Release Candidate + +on: + workflow_dispatch: + inputs: + rn_version: + description: 'React Native plugin version for this RC (e.g., 6.18.0-rc1)' + required: true + type: string + ios_sdk_version: + description: 'iOS native AppsFlyer SDK version (e.g., 6.18.0)' + required: true + type: string + android_sdk_version: + description: 'Android native AppsFlyer SDK version (e.g., 6.18.0)' + required: true + type: string + base_branch: + description: 'Base branch to create the release branch from' + required: false + default: development + type: string + pc_version: + description: 'PurchaseConnector iOS version override (leave empty to auto-fetch latest from GitHub)' + required: false + default: '' + type: string + skip_unit: + description: 'Skip unit tests and linting inside Lint, Test & Build' + required: false + type: boolean + default: false + skip_builds: + description: 'Skip Android + iOS release builds inside Lint, Test & Build' + required: false + type: boolean + default: false + skip_e2e: + description: 'Skip RC-E2E iOS + Android jobs (blocks publish-rc)' + required: false + type: boolean + default: false + dry_run: + description: 'Do not publish RC to npm (still opens PR and creates prerelease)' + required: false + type: boolean + default: true + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # =========================================================================== + # Job 1: Validate Inputs & Compute Branch + # =========================================================================== + + validate-release: + name: Validate Inputs & Compute Branch + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.compute.outputs.version }} + base_version: ${{ steps.compute.outputs.base_version }} + is_rc: ${{ steps.compute.outputs.is_rc }} + is_valid: ${{ steps.compute.outputs.is_valid }} + base_branch: ${{ steps.compute.outputs.base_branch }} + release_branch: ${{ steps.compute.outputs.release_branch }} + ios_sdk_version: ${{ steps.compute.outputs.ios_sdk_version }} + android_sdk_version: ${{ steps.compute.outputs.android_sdk_version }} + is_dry_run: ${{ steps.dry-run.outputs.value }} + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Resolve dry_run + id: dry-run + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_DRY: ${{ github.event.inputs.dry_run }} + run: | + set -euo pipefail + case "$EVENT_NAME" in + workflow_dispatch) + if [[ "$DISPATCH_DRY" == "true" ]]; then + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "value=false" >> "$GITHUB_OUTPUT" + fi + ;; + *) + echo "value=false" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Validate and compute + id: compute + env: + VERSION: ${{ github.event.inputs.rn_version }} + IOS_VER: ${{ github.event.inputs.ios_sdk_version }} + AND_VER: ${{ github.event.inputs.android_sdk_version }} + BASE_BRANCH_INPUT: ${{ github.event.inputs.base_branch }} + run: | + set -euo pipefail + + if [[ -z "$VERSION" || -z "$IOS_VER" || -z "$AND_VER" ]]; then + echo "Missing required inputs"; exit 1 + fi + + # RN version format: X.Y.Z-rcN (no +build) + if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc[0-9]+$ ]]; then + echo "rn_version must be X.Y.Z-rcN (e.g., 6.18.0-rc1)"; exit 1 + fi + if [[ ! $IOS_VER =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "ios_sdk_version must be X.Y.Z"; exit 1 + fi + if [[ ! $AND_VER =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "android_sdk_version must be X.Y.Z"; exit 1 + fi + + # Compute base version (remove -rcN) + BASE_VERSION=$(echo "$VERSION" | sed 's/-rc[0-9]*$//') + + MAJOR_MINOR=$(echo "$BASE_VERSION" | grep -oE '^[0-9]+\.[0-9]+') + MAJOR=$(echo "$BASE_VERSION" | grep -oE '^[0-9]+') + RELEASE_BRANCH="releases/${MAJOR}.x.x/${MAJOR_MINOR}.x/${VERSION}" + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT + echo "is_rc=true" >> $GITHUB_OUTPUT + echo "is_valid=true" >> $GITHUB_OUTPUT + echo "base_branch=$BASE_BRANCH_INPUT" >> $GITHUB_OUTPUT + echo "release_branch=$RELEASE_BRANCH" >> $GITHUB_OUTPUT + echo "ios_sdk_version=$IOS_VER" >> $GITHUB_OUTPUT + echo "android_sdk_version=$AND_VER" >> $GITHUB_OUTPUT + + # =========================================================================== + # Job 2: Lint, Test & Build (reusable) + # =========================================================================== + + run-ci: + name: Lint, Test & Build + needs: validate-release + if: ${{ needs.validate-release.outputs.is_valid == 'true' }} + uses: ./.github/workflows/lint-test-build.yml + with: + skip_unit: ${{ github.event.inputs.skip_unit == 'true' }} + skip_builds: ${{ github.event.inputs.skip_builds == 'true' }} + secrets: inherit + + # =========================================================================== + # Job 3: Create/Update Release Branch and Apply Changes + # =========================================================================== + + prepare-branch: + name: Create Release Branch & Apply Changes + runs-on: ubuntu-latest + needs: [validate-release] + if: always() && needs.validate-release.outputs.is_valid == 'true' + outputs: + release_branch: ${{ steps.push.outputs.release_branch }} + steps: + - name: Checkout base branch + uses: actions/checkout@v5 + with: + ref: ${{ needs.validate-release.outputs.base_branch }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Create release branch + id: branch + run: | + set -e + REL_BRANCH="${{ needs.validate-release.outputs.release_branch }}" + echo "Target release branch: $REL_BRANCH" + if git ls-remote --exit-code --heads origin "$REL_BRANCH" >/dev/null 2>&1; then + echo "Branch already exists on remote. Checking it out." + git fetch origin "$REL_BRANCH":"$REL_BRANCH" + git checkout "$REL_BRANCH" + else + git checkout -b "$REL_BRANCH" + fi + + - name: Update package.json version + env: + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + echo "Setting package.json version to $VERSION" + npm version "$VERSION" --no-git-tag-version + grep '"version"' package.json + + - name: Update Android SDK fallback in build.gradle + env: + AND_VER: ${{ needs.validate-release.outputs.android_sdk_version }} + run: | + sed -i.bak "s/af-android-sdk:\${safeExtGet('appsflyerVersion', '[^']*')}/af-android-sdk:\${safeExtGet('appsflyerVersion', '${AND_VER}')}/" android/build.gradle + rm -f android/build.gradle.bak + grep "af-android-sdk:" android/build.gradle + + - name: Update iOS SDK deps in podspec + env: + IOS_VER: ${{ needs.validate-release.outputs.ios_sdk_version }} + PC_VER_INPUT: ${{ github.event.inputs.pc_version }} + run: | + # AppsFlyerFramework (default) + sed -i.bak "s/'AppsFlyerFramework', '[^']*'/'AppsFlyerFramework', '${IOS_VER}'/" react-native-appsflyer.podspec + # AppsFlyerFramework/Strict + sed -i.bak "s|'AppsFlyerFramework/Strict', '[^']*'|'AppsFlyerFramework/Strict', '${IOS_VER}'|" react-native-appsflyer.podspec + + # PurchaseConnector (conditional dep, has its own version) + if grep -q "PurchaseConnector" react-native-appsflyer.podspec; then + PC_VER=$(curl -s "https://api.github.com/repos/AppsFlyerSDK/appsflyer-apple-purchase-connector/releases/latest" | jq -r '.tag_name') + if [[ -n "$PC_VER" && "$PC_VER" != "null" ]]; then + echo "Auto-fetched PurchaseConnector version: $PC_VER" + elif [[ -n "${PC_VER_INPUT:-}" ]]; then + PC_VER="$PC_VER_INPUT" + echo "Using manual PurchaseConnector version: $PC_VER" + else + echo "::error::Could not fetch latest PurchaseConnector version and no pc_version input provided." + exit 1 + fi + sed -i.bak "s/'PurchaseConnector', '[^']*'/'PurchaseConnector', '${PC_VER}'/" react-native-appsflyer.podspec + fi + + rm -f react-native-appsflyer.podspec.bak + echo "Updated podspec lines:" + grep -n "AppsFlyerFramework\|PurchaseConnector" react-native-appsflyer.podspec || true + + - name: Update plugin version constants + env: + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + echo "Updating PLUGIN_VERSION constants to: $VERSION" + + # Android - RNAppsFlyerConstants.java + sed -i.bak "s/PLUGIN_VERSION = \"[^\"]*\"/PLUGIN_VERSION = \"${VERSION}\"/" android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java + rm -f android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java.bak + echo "Android:" && grep "PLUGIN_VERSION" android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java + + # iOS - RNAppsFlyer.h + sed -i.bak "s/kAppsFlyerPluginVersion[[:space:]]*= @\"[^\"]*\"/kAppsFlyerPluginVersion = @\"${VERSION}\"/" ios/RNAppsFlyer.h + rm -f ios/RNAppsFlyer.h.bak + echo "iOS:" && grep "kAppsFlyerPluginVersion" ios/RNAppsFlyer.h + + - name: Update README SDK version badges + env: + IOS_VER: ${{ needs.validate-release.outputs.ios_sdk_version }} + AND_VER: ${{ needs.validate-release.outputs.android_sdk_version }} + run: | + sed -i.bak -E "s/Android AppsFlyer SDK \*\*v[0-9.]+\*\*/Android AppsFlyer SDK **v${AND_VER}**/" README.md + sed -i.bak -E "s/iOS AppsFlyer SDK \*\*v[0-9.]+\*\*/iOS AppsFlyer SDK **v${IOS_VER}**/" README.md + rm -f README.md.bak + grep -n "AppsFlyer SDK \*\*v" README.md + + - name: Update CHANGELOG.md + env: + VERSION: ${{ needs.validate-release.outputs.version }} + IOS_VER: ${{ needs.validate-release.outputs.ios_sdk_version }} + AND_VER: ${{ needs.validate-release.outputs.android_sdk_version }} + run: | + CHANGELOG_ENTRY="## ${VERSION}\n Release date: *$(date +%Y-%m-%d)*\n\n### Changes\n- Android SDK ${AND_VER}\n- iOS SDK ${IOS_VER}\n- TODO: Add specific changes before merging\n" + printf '%b\n' "$CHANGELOG_ENTRY" | cat - CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md + head -10 CHANGELOG.md + + - name: Commit & push changes + id: push + env: + VERSION: ${{ needs.validate-release.outputs.version }} + IOS_VER: ${{ needs.validate-release.outputs.ios_sdk_version }} + AND_VER: ${{ needs.validate-release.outputs.android_sdk_version }} + run: | + set -e + REL_BRANCH='${{ needs.validate-release.outputs.release_branch }}' + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + if [[ -n $(git status -s) ]]; then + git add package.json package-lock.json android/ ios/ react-native-appsflyer.podspec README.md CHANGELOG.md || true + git commit -m "chore: prepare RC ${VERSION} (iOS ${IOS_VER}, Android ${AND_VER})" + git push --set-upstream origin "$REL_BRANCH" + else + echo "No changes to commit" + if ! git ls-remote --exit-code --heads origin "$REL_BRANCH" >/dev/null 2>&1; then + git push --set-upstream origin "$REL_BRANCH" + fi + fi + echo "release_branch=$REL_BRANCH" >> $GITHUB_OUTPUT + + # =========================================================================== + # Stage RC-E2E: iOS + # =========================================================================== + + run-e2e-ios: + name: RC-E2E iOS + needs: [validate-release, prepare-branch] + if: ${{ needs.validate-release.outputs.is_valid == 'true' && github.event.inputs.skip_e2e != 'true' }} + uses: ./.github/workflows/ios-e2e.yml + with: + ref: ${{ needs.prepare-branch.outputs.release_branch }} + secrets: inherit + + # =========================================================================== + # Stage RC-E2E: Android + # =========================================================================== + + run-e2e-android: + name: RC-E2E Android + needs: [validate-release, prepare-branch] + if: ${{ needs.validate-release.outputs.is_valid == 'true' && github.event.inputs.skip_e2e != 'true' }} + uses: ./.github/workflows/android-e2e.yml + with: + ref: ${{ needs.prepare-branch.outputs.release_branch }} + secrets: inherit + + # =========================================================================== + # Pre-publish gate: aggregate run-ci + RC-E2E iOS + RC-E2E Android + # =========================================================================== + + pre-publish-gate: + name: Pre-publish Gate + runs-on: ubuntu-latest + needs: [validate-release, run-ci, run-e2e-ios, run-e2e-android] + if: always() && needs.validate-release.outputs.is_valid == 'true' + outputs: + passed: ${{ steps.aggregate.outputs.passed }} + ci_result: ${{ steps.aggregate.outputs.ci_result }} + e2e_ios_result: ${{ steps.aggregate.outputs.e2e_ios_result }} + e2e_android_result: ${{ steps.aggregate.outputs.e2e_android_result }} + steps: + - name: Aggregate pre-publish results + id: aggregate + env: + CI_RESULT: ${{ needs.run-ci.result }} + E2E_IOS_RESULT: ${{ needs.run-e2e-ios.result }} + E2E_ANDROID_RESULT: ${{ needs.run-e2e-android.result }} + run: | + set -euo pipefail + echo "Lint, Test & Build : $CI_RESULT" + echo "RC-E2E iOS : $E2E_IOS_RESULT" + echo "RC-E2E Android : $E2E_ANDROID_RESULT" + + ci_ok=false + ios_ok=false + android_ok=false + + if [[ "$CI_RESULT" == "success" || "$CI_RESULT" == "skipped" ]]; then + ci_ok=true + fi + if [[ "$E2E_IOS_RESULT" == "success" ]]; then + ios_ok=true + fi + if [[ "$E2E_ANDROID_RESULT" == "success" ]]; then + android_ok=true + fi + + { + echo "ci_result=$CI_RESULT" + echo "e2e_ios_result=$E2E_IOS_RESULT" + echo "e2e_android_result=$E2E_ANDROID_RESULT" + } >> "$GITHUB_OUTPUT" + + if $ci_ok && $ios_ok && $android_ok; then + echo "passed=true" >> "$GITHUB_OUTPUT" + echo "Pre-publish gate passed" + exit 0 + fi + + echo "passed=false" >> "$GITHUB_OUTPUT" + echo "Pre-publish gate failed" + $ci_ok || echo " - Lint, Test & Build did not pass ($CI_RESULT)" + $ios_ok || echo " - RC-E2E iOS did not pass ($E2E_IOS_RESULT)" + $android_ok || echo " - RC-E2E Android did not pass ($E2E_ANDROID_RESULT)" + exit 1 + + # =========================================================================== + # Validate Jira fix version (gates publish-rc) + # =========================================================================== + + validate-jira: + name: Validate Jira Fix Version + runs-on: ubuntu-latest + needs: [validate-release, pre-publish-gate] + if: needs.validate-release.outputs.is_valid == 'true' && needs.pre-publish-gate.outputs.passed == 'true' + steps: + - name: Verify Jira fix version exists + env: + CI_JIRA_EMAIL: ${{ secrets.CI_JIRA_EMAIL }} + CI_JIRA_TOKEN: ${{ secrets.CI_JIRA_TOKEN }} + CI_JIRA_DOMAIN: ${{ secrets.CI_JIRA_DOMAIN }} + BASE_VERSION: ${{ needs.validate-release.outputs.base_version }} + run: | + set -euo pipefail + JIRA_FIX_VERSION="React Native SDK v${BASE_VERSION}" + JIRA_DOMAIN="${CI_JIRA_DOMAIN:-appsflyer.atlassian.net}" + + echo "Looking for Jira fix version: $JIRA_FIX_VERSION" + + if [[ -z "${CI_JIRA_EMAIL:-}" || -z "${CI_JIRA_TOKEN:-}" ]]; then + echo "::warning::Jira credentials not configured, skipping validation" + exit 0 + fi + + RESPONSE=$(curl -s -u "${CI_JIRA_EMAIL}:${CI_JIRA_TOKEN}" \ + "https://${JIRA_DOMAIN}/rest/api/2/project/SDKRC/versions" \ + | jq -r ".[] | select(.name == \"$JIRA_FIX_VERSION\") | .name") + + if [[ -z "$RESPONSE" ]]; then + echo "::error::Jira fix version '$JIRA_FIX_VERSION' not found. Create it before publishing." + exit 1 + fi + echo "Jira fix version found: $RESPONSE" + + # =========================================================================== + # Publish RC to npm + # =========================================================================== + + publish-rc: + name: Publish RC to npm + runs-on: ubuntu-latest + needs: [validate-release, prepare-branch, pre-publish-gate, validate-jira] + if: needs.validate-release.outputs.is_valid == 'true' && needs.pre-publish-gate.outputs.passed == 'true' + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ needs.prepare-branch.outputs.release_branch }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Validate package (dry-run) + run: npm pack --dry-run + + - name: RC dry-run active -- skipping publish + if: ${{ needs.validate-release.outputs.is_dry_run == 'true' }} + run: | + echo "RC dry_run is true -- will not publish to npm." + + - name: Publish RC to npm + if: ${{ needs.validate-release.outputs.is_dry_run != 'true' }} + env: + CI_NPM_TOKEN: ${{ secrets.CI_NPM_TOKEN }} + run: | + if [[ -z "${CI_NPM_TOKEN}" ]]; then + echo "CI_NPM_TOKEN is missing"; exit 1 + fi + echo "//registry.npmjs.org/:_authToken=${CI_NPM_TOKEN}" > ~/.npmrc + npm publish --tag rc + rm -f ~/.npmrc + + # =========================================================================== + # Create Pre-Release Tag + # =========================================================================== + + create-prerelease: + name: Create Pre-Release + runs-on: ubuntu-latest + needs: [validate-release, prepare-branch, publish-rc] + if: always() && needs.validate-release.outputs.is_rc == 'true' && needs.publish-rc.result == 'success' + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ needs.prepare-branch.outputs.release_branch }} + + - name: Generate release notes + id: release-notes + env: + VERSION: ${{ needs.validate-release.outputs.version }} + IOS_VER: ${{ needs.validate-release.outputs.ios_sdk_version }} + AND_VER: ${{ needs.validate-release.outputs.android_sdk_version }} + run: | + cat > release_notes.md << EOF + # AppsFlyer React Native Plugin - Release Candidate $VERSION + + ## Release Candidate for Testing + + This is a pre-release version for QA testing. Do not use in production. + + ## Testing Instructions + + \`\`\`bash + npm install react-native-appsflyer@${VERSION} --save + \`\`\` + + ## SDK Versions + + - Android AppsFlyer SDK: ${AND_VER} + - iOS AppsFlyer SDK: ${IOS_VER} + + --- + + **Note**: This is a pre-release and should not be used in production applications. + EOF + + - name: Create GitHub Pre-Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.validate-release.outputs.version }} + name: Release Candidate ${{ needs.validate-release.outputs.version }} + body_path: release_notes.md + draft: false + prerelease: true + generate_release_notes: false + token: ${{ secrets.GITHUB_TOKEN }} + + # =========================================================================== + # Open PR to master + # =========================================================================== + + open-pr: + name: Open PR to master + runs-on: ubuntu-latest + needs: [validate-release, prepare-branch, publish-rc] + if: always() && needs.publish-rc.result == 'success' + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ needs.prepare-branch.outputs.release_branch }} + + - name: Create or update PR + uses: actions/github-script@v7 + with: + script: | + const version = '${{ needs.validate-release.outputs.base_version }}'; + const rcVersion = '${{ needs.validate-release.outputs.version }}'; + const head = '${{ needs.prepare-branch.outputs.release_branch }}'; + const base = 'master'; + const androidVersion = '${{ needs.validate-release.outputs.android_sdk_version }}'; + const iosVersion = '${{ needs.validate-release.outputs.ios_sdk_version }}'; + + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${head}` + }); + + const body = [ + `### Release ${version}`, + '', + `- Android SDK: ${androidVersion}`, + `- iOS SDK: ${iosVersion}`, + '', + '```bash', + `npm install react-native-appsflyer@${rcVersion} --save`, + '```', + '', + 'This PR was opened by the RC workflow.' + ].join('\n'); + + if (prs.length > 0) { + const pr = prs[0]; + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + title: `Release ${version}`, + body + }); + core.setOutput('pr_number', pr.number); + } else { + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + head, + base, + title: `Release ${version}`, + body, + maintainer_can_modify: true + }); + core.setOutput('pr_number', pr.number); + } + + # =========================================================================== + # Notify Team + # =========================================================================== + + notify-team: + name: Notify Team + runs-on: ubuntu-latest + needs: + - validate-release + - prepare-branch + - run-ci + - run-e2e-ios + - run-e2e-android + - pre-publish-gate + - validate-jira + - publish-rc + - create-prerelease + if: always() + + steps: + - name: Determine status and failed stage + id: status + env: + VALIDATE_RESULT: ${{ needs.validate-release.result }} + PREPARE_RESULT: ${{ needs.prepare-branch.result }} + GATE_RESULT: ${{ needs.pre-publish-gate.result }} + JIRA_RESULT: ${{ needs.validate-jira.result }} + PUBLISH_RESULT: ${{ needs.publish-rc.result }} + PRERELEASE_RESULT: ${{ needs.create-prerelease.result }} + run: | + set -euo pipefail + if [[ "$VALIDATE_RESULT" == "success" \ + && "$PREPARE_RESULT" == "success" \ + && "$GATE_RESULT" == "success" \ + && "$JIRA_RESULT" == "success" \ + && "$PUBLISH_RESULT" == "success" \ + && "$PRERELEASE_RESULT" == "success" ]]; then + echo "success=true" >> "$GITHUB_OUTPUT" + echo "failed_stage=" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "success=false" >> "$GITHUB_OUTPUT" + if [[ "$VALIDATE_RESULT" != "success" ]]; then + echo "failed_stage=validate-release" >> "$GITHUB_OUTPUT" + elif [[ "$PREPARE_RESULT" != "success" ]]; then + echo "failed_stage=prepare-branch" >> "$GITHUB_OUTPUT" + elif [[ "$GATE_RESULT" != "success" ]]; then + echo "failed_stage=pre-publish-gate" >> "$GITHUB_OUTPUT" + elif [[ "$JIRA_RESULT" != "success" ]]; then + echo "failed_stage=validate-jira" >> "$GITHUB_OUTPUT" + elif [[ "$PUBLISH_RESULT" != "success" ]]; then + echo "failed_stage=publish-rc" >> "$GITHUB_OUTPUT" + else + echo "failed_stage=create-prerelease" >> "$GITHUB_OUTPUT" + fi + + - name: Format pre-publish leg results + id: legs + env: + CI_RESULT: ${{ needs.run-ci.result }} + E2E_IOS_RESULT: ${{ needs.run-e2e-ios.result }} + E2E_ANDROID_RESULT: ${{ needs.run-e2e-android.result }} + run: | + set -euo pipefail + icon_for() { + local kind="$1" result="$2" + case "$result" in + success) echo ":white_check_mark:" ;; + skipped) + if [[ "$kind" == "ci" ]]; then echo ":fast_forward:"; else echo ":x:"; fi ;; + failure) echo ":x:" ;; + cancelled) echo ":no_entry_sign:" ;; + *) echo ":grey_question:" ;; + esac + } + CI_ICON=$(icon_for ci "$CI_RESULT") + IOS_ICON=$(icon_for e2e "$E2E_IOS_RESULT") + ANDROID_ICON=$(icon_for e2e "$E2E_ANDROID_RESULT") + { + echo "ci_icon=$CI_ICON" + echo "ios_icon=$IOS_ICON" + echo "android_icon=$ANDROID_ICON" + echo "ci_result=$CI_RESULT" + echo "e2e_ios_result=$E2E_IOS_RESULT" + echo "e2e_android_result=$E2E_ANDROID_RESULT" + } >> "$GITHUB_OUTPUT" + + - name: Checkout release branch + if: steps.status.outputs.success == 'true' + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ needs.prepare-branch.outputs.release_branch }} + + - name: Extract SDK versions and changelog + id: extract-info + if: steps.status.outputs.success == 'true' + run: | + VERSION="${{ needs.validate-release.outputs.version }}" + BASE_VERSION=$(echo "$VERSION" | sed 's/-rc[0-9]*$//') + + echo "android_sdk=${{ needs.validate-release.outputs.android_sdk_version }}" >> $GITHUB_OUTPUT + echo "ios_sdk=${{ needs.validate-release.outputs.ios_sdk_version }}" >> $GITHUB_OUTPUT + + # Extract changelog for this version + if [ -f "CHANGELOG.md" ]; then + CHANGELOG=$(awk "/## $VERSION/,/^## [0-9]/" CHANGELOG.md | grep "^-" | sed 's/^- //' | head -5) + if [ -z "$CHANGELOG" ]; then + CHANGELOG="Check CHANGELOG.md for details" + fi + else + CHANGELOG="Check release notes for details" + fi + + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Fetch Jira tickets + id: jira-tickets + if: steps.status.outputs.success == 'true' + continue-on-error: true + env: + CI_JIRA_EMAIL: ${{ secrets.CI_JIRA_EMAIL }} + CI_JIRA_TOKEN: ${{ secrets.CI_JIRA_TOKEN }} + CI_JIRA_DOMAIN: ${{ secrets.CI_JIRA_DOMAIN }} + run: | + set +e + VERSION="${{ needs.validate-release.outputs.version }}" + BASE_VERSION=$(echo "$VERSION" | sed 's/-rc[0-9]*$//') + JIRA_FIX_VERSION="React Native SDK v$BASE_VERSION" + JIRA_DOMAIN="${CI_JIRA_DOMAIN:-appsflyer.atlassian.net}" + + echo "Looking for Jira tickets with fix version: $JIRA_FIX_VERSION" + + if [[ -z "${CI_JIRA_EMAIL:-}" ]] || [[ -z "${CI_JIRA_TOKEN:-}" ]]; then + echo "Jira credentials not configured" + echo "tickets=No assigned fix version found" >> $GITHUB_OUTPUT + exit 0 + fi + + JQL_QUERY="fixVersion=\"${JIRA_FIX_VERSION}\"" + ENCODED_JQL=$(echo "$JQL_QUERY" | jq -sRr @uri) + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -u "${CI_JIRA_EMAIL}:${CI_JIRA_TOKEN}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + "https://${JIRA_DOMAIN}/rest/api/3/search/jql?jql=${ENCODED_JQL}&fields=key,summary&maxResults=20") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [[ "$HTTP_CODE" != "200" ]]; then + echo "Jira API request failed with status $HTTP_CODE" + echo "tickets=No assigned fix version found" >> $GITHUB_OUTPUT + exit 0 + fi + + TICKETS=$(echo "$BODY" | jq -r '.issues[]? | "https://'"${JIRA_DOMAIN}"'/browse/\(.key) - \(.fields.summary)"' 2>/dev/null | head -10) + + if [ -z "$TICKETS" ]; then + echo "tickets=No assigned fix version found" >> $GITHUB_OUTPUT + else + echo "tickets<> $GITHUB_OUTPUT + echo "$TICKETS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + - name: Send Slack notification (Success) + if: steps.status.outputs.success == 'true' && needs.validate-release.outputs.is_dry_run != 'true' + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "\n:react::react::react::react::react::react::react::react::react::react::react::react:\n\n*React Native Release Candidate:*\nreact-native-appsflyer: ${{ needs.validate-release.outputs.version }} is ready for QA testing.\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n\n*Testing Instructions:*\n```\nnpm install react-native-appsflyer@${{ needs.validate-release.outputs.version }} --save\n```\n\n*Sources:*\n:github: https://github.com/${{ github.repository }}/tree/${{ github.ref_name }}\n:github: Release: https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate-release.outputs.version }}\n:github: Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Changes and fixes:*\n${{ steps.extract-info.outputs.changelog }}\n\n*Linked tickets and issues:*\n${{ steps.jira-tickets.outputs.tickets }}\n\n*Native SDKs:*\n:android: ${{ steps.extract-info.outputs.android_sdk }}\n:apple: ${{ steps.extract-info.outputs.ios_sdk }}\n\n:react::react::react::react::react::react::react::react::react::react::react::react:" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + + - name: Send failure notification + if: steps.status.outputs.success == 'false' && needs.validate-release.outputs.is_dry_run != 'true' + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "\n:warning: *React Native RC failed at `${{ steps.status.outputs.failed_stage }}`*\n\nVersion: ${{ needs.validate-release.outputs.version }}\nBranch: ${{ github.ref_name }}\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n\n*Downstream stages:*\n- prepare-branch: ${{ needs.prepare-branch.result }}\n- pre-publish-gate: ${{ needs.pre-publish-gate.result }}\n- validate-jira: ${{ needs.validate-jira.result }}\n- publish-rc: ${{ needs.publish-rc.result }}\n- create-prerelease: ${{ needs.create-prerelease.result }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + + # =========================================================================== + # RC Summary + # =========================================================================== + + rc-summary: + name: RC Summary + runs-on: ubuntu-latest + needs: [validate-release, run-ci, prepare-branch, run-e2e-ios, run-e2e-android, pre-publish-gate, validate-jira, publish-rc, create-prerelease] + if: always() + + steps: + - name: Display RC Summary + run: | + echo "=========================================" + echo "RC Release Summary" + echo "=========================================" + echo "Version: ${{ needs.validate-release.outputs.version }}" + echo "Dry Run: ${{ needs.validate-release.outputs.is_dry_run }}" + echo "-----------------------------------------" + echo "RC-PREP validate: ${{ needs.validate-release.result }}" + echo "Lint, Test & Build: ${{ needs.run-ci.result }}" + echo "RC-PREP branch: ${{ needs.prepare-branch.result }}" + echo "RC-E2E iOS: ${{ needs.run-e2e-ios.result }}" + echo "RC-E2E Android: ${{ needs.run-e2e-android.result }}" + echo "Pre-publish gate: ${{ needs.pre-publish-gate.result }}" + echo "Validate Jira: ${{ needs.validate-jira.result }}" + echo "RC-PUBLISH: ${{ needs.publish-rc.result }}" + echo "Pre-release tag + PR: ${{ needs.create-prerelease.result }}" + echo "=========================================" + + if [[ "${{ needs.validate-release.result }}" == "success" ]] && \ + [[ "${{ needs.pre-publish-gate.result }}" == "success" ]] && \ + [[ "${{ needs.publish-rc.result }}" == "success" ]] && \ + [[ "${{ needs.create-prerelease.result }}" == "success" ]]; then + echo "RC Release Process Completed Successfully" + else + echo "RC Release Process Failed" + echo "Check the logs above for details" + exit 1 + fi diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml new file mode 100644 index 00000000..52196234 --- /dev/null +++ b/.github/workflows/rc-smoke.yml @@ -0,0 +1,485 @@ +# ============================================================================= +# RC Smoke — post-publish validation against the npm RC artifact +# ============================================================================= +# +# Stage: RC-SMOKE in the RC pipeline. +# +# Fires automatically after rc-release.yml ("RC - Release Candidate") completes +# with conclusion: success. Synthesizes example_rc_smoke/ from the demo app +# with react-native-appsflyer pinned to the RC version from npm, runs +# SMOKE-001/002/003 via af-scenario-runner.sh on both platforms, and posts a +# check_run named rc-smoke/npm on the release branch head SHA. That check_run +# is what promote-release.yml verifies before stripping -rcN. +# +# Manual dispatch is supported for reruns (rc_version input). +# +# When the parent RC-release run was dry_run=true, this workflow still fires +# but short-circuits and posts a skipped check-run so promotion can +# distinguish "not applicable" from "failing". +# ============================================================================= + +name: RC Smoke - npm artifact + +on: + workflow_run: + workflows: ["RC - Release Candidate"] + types: [completed] + workflow_dispatch: + inputs: + rc_version: + description: 'RC version to smoke, e.g. 6.18.0-rc1 (must exist on npm)' + required: true + type: string + release_branch: + description: 'Release branch the smoke result should be associated with' + required: true + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha || github.event.inputs.release_branch }} + cancel-in-progress: false + +jobs: + # =========================================================================== + # Resolve RC version and branch context. Short-circuit on dry-run or failure + # of the parent RC-release run. + # =========================================================================== + resolve: + name: Resolve RC context + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.decide.outputs.should_run }} + skip_reason: ${{ steps.decide.outputs.skip_reason }} + rc_version: ${{ steps.decide.outputs.rc_version }} + release_branch: ${{ steps.decide.outputs.release_branch }} + head_sha: ${{ steps.decide.outputs.head_sha }} + steps: + - name: Log trigger context + run: | + echo "event_name=${{ github.event_name }}" + echo "workflow_run.conclusion=${{ github.event.workflow_run.conclusion }}" + echo "workflow_run.head_branch=${{ github.event.workflow_run.head_branch }}" + echo "workflow_run.head_sha=${{ github.event.workflow_run.head_sha }}" + + - name: Checkout release branch (workflow_run path) + if: github.event_name == 'workflow_run' + uses: actions/checkout@v5 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 1 + + - name: Checkout release branch (manual dispatch path) + if: github.event_name == 'workflow_dispatch' + uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.release_branch }} + fetch-depth: 1 + + - name: Decide whether to run + id: decide + env: + EVENT_NAME: ${{ github.event_name }} + PARENT_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + INPUT_VERSION: ${{ github.event.inputs.rc_version }} + INPUT_BRANCH: ${{ github.event.inputs.release_branch }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + RC_VERSION="$INPUT_VERSION" + REL_BRANCH="$INPUT_BRANCH" + HEAD="$(git rev-parse HEAD)" + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "skip_reason=" >> "$GITHUB_OUTPUT" + echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT" + echo "release_branch=$REL_BRANCH" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # workflow_run path + if [[ "$PARENT_CONCLUSION" != "success" ]]; then + echo "Parent RC-release run was $PARENT_CONCLUSION; skipping smoke." + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "skip_reason=parent_not_success" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "release_branch=$HEAD_BRANCH" >> "$GITHUB_OUTPUT" + echo "rc_version=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # workflow_run listeners cannot read the parent's inputs directly, + # so we infer state from the release branch checkout: package.json + # carries the bumped RC version (committed by prepare-branch on both + # real and dry runs), and the npm API tells us if it was actually + # published. Dry runs naturally fail the visibility poll below and + # emit a skipped check-run. + VERSION=$(node -p "require('./package.json').version") + if [[ ! "$VERSION" =~ -rc[0-9]+$ ]]; then + echo "package.json version is not an RC ($VERSION); skipping." + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "skip_reason=not_rc_version" >> "$GITHUB_OUTPUT" + echo "rc_version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_branch=$HEAD_BRANCH" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # npm registry indexing can lag a few minutes after a successful + # publish. Poll until the RC version appears, with a hard timeout. + # Only emit a skipped check if it never shows up (genuine dry run / + # publish failure that the success-conclusion check missed). + MAX_WAIT_SECONDS=900 + POLL_INTERVAL_SECONDS=30 + ELAPSED=0 + FOUND=false + while (( ELAPSED < MAX_WAIT_SECONDS )); do + if npm view "react-native-appsflyer@$VERSION" version 2>/dev/null | grep -Fxq "$VERSION"; then + echo "RC $VERSION visible on npm after ${ELAPSED}s." + FOUND=true + break + fi + echo "RC $VERSION not yet on npm (waited ${ELAPSED}s, retrying in ${POLL_INTERVAL_SECONDS}s)" + sleep "$POLL_INTERVAL_SECONDS" + ELAPSED=$(( ELAPSED + POLL_INTERVAL_SECONDS )) + done + + if [[ "$FOUND" != "true" ]]; then + echo "RC $VERSION never appeared on npm within ${MAX_WAIT_SECONDS}s; emitting skipped check." + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "skip_reason=not_on_npm" >> "$GITHUB_OUTPUT" + echo "rc_version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_branch=$HEAD_BRANCH" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "skip_reason=" >> "$GITHUB_OUTPUT" + echo "rc_version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_branch=$HEAD_BRANCH" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + + # =========================================================================== + # iOS smoke against the published RC on npm. + # =========================================================================== + smoke-ios: + name: rc-smoke iOS + needs: resolve + if: needs.resolve.outputs.should_run == 'true' + runs-on: macos-15 + steps: + - name: Checkout release branch + uses: actions/checkout@v5 + with: + ref: ${{ needs.resolve.outputs.head_sha }} + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Synthesize example_rc_smoke from demo app + env: + RC_VERSION: ${{ needs.resolve.outputs.rc_version }} + run: | + set -euo pipefail + rsync -a \ + --exclude=node_modules \ + --exclude=.env \ + --exclude=build \ + --exclude=ios/Pods \ + --exclude=ios/build \ + --exclude=android/.gradle \ + --exclude=android/build \ + --exclude='.metro-*' \ + example/ example_rc_smoke/ + + cd example_rc_smoke + npm pkg set "dependencies.react-native-appsflyer=$RC_VERSION" + echo "Pinned react-native-appsflyer to $RC_VERSION" + grep "react-native-appsflyer" package.json + + - name: Write .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: printf '%s\n' "$ENV_FILE" > example_rc_smoke/.env + + - name: Select Xcode version + run: | + XCODE=$(ls /Applications | grep -E "^Xcode_[0-9]" | sort -V | tail -1) + sudo xcode-select -s "/Applications/$XCODE" + xcodebuild -version + + - name: Boot iOS simulator + run: | + DEVICES_JSON=$(xcrun simctl list -j devices available) + UDID=$(echo "$DEVICES_JSON" | jq -r ' + .devices + | to_entries + | map(select(.key | test("iOS-1[78]"))) + | sort_by(.key) | reverse + | map( + . as $rt + | $rt.value + | map(select(.name | test("^iPhone 1[5-7]$"))) + | first + | (if . then {udid} else empty end) + ) + | first // empty + | .udid // empty + ') + if [[ -z "$UDID" ]]; then + echo "No plain iPhone 15/16/17 on iOS 17/18 found; falling back to first available iPhone." + UDID=$(echo "$DEVICES_JSON" | jq -r '.devices[][] | select(.name | test("^iPhone")) | .udid' | head -1) + fi + [[ -z "$UDID" ]] && { echo "::error::No iOS simulator available"; exit 1; } + xcrun simctl boot "$UDID" + xcrun simctl bootstatus "$UDID" -b + echo "IOS_SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV" + + - name: Install npm RC and CocoaPods + working-directory: example_rc_smoke + run: | + set -euo pipefail + # npm CDN propagation can lag; retry with backoff. + ATTEMPTS=5 + SLEEP=30 + for i in $(seq 1 "$ATTEMPTS"); do + echo "npm install attempt $i/$ATTEMPTS" + if npm install; then + echo "npm install succeeded on attempt $i" + break + fi + if [[ "$i" == "$ATTEMPTS" ]]; then + echo "npm install failed after $ATTEMPTS attempts" >&2 + exit 1 + fi + npm cache clean --force || true + sleep "$SLEEP" + done + cd ios && pod install + + - name: Run smoke (SMOKE-001/002/003) + run: ./scripts/af-scenario-runner.sh --platform ios --plan .af-smoke/rc-test-plan.json + + - name: Upload smoke reports + if: always() + uses: actions/upload-artifact@v5 + with: + name: rc-smoke-ios-${{ github.run_number }} + path: .af-smoke/reports/ + retention-days: 30 + + # =========================================================================== + # Android smoke against the published RC on npm. + # =========================================================================== + smoke-android: + name: rc-smoke Android + needs: resolve + if: needs.resolve.outputs.should_run == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout release branch + uses: actions/checkout@v5 + with: + ref: ${{ needs.resolve.outputs.head_sha }} + fetch-depth: 1 + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls -l /dev/kvm + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Synthesize example_rc_smoke from demo app + env: + RC_VERSION: ${{ needs.resolve.outputs.rc_version }} + run: | + set -euo pipefail + rsync -a \ + --exclude=node_modules \ + --exclude=.env \ + --exclude=build \ + --exclude=ios/Pods \ + --exclude=ios/build \ + --exclude=android/.gradle \ + --exclude=android/build \ + --exclude='.metro-*' \ + example/ example_rc_smoke/ + + cd example_rc_smoke + npm pkg set "dependencies.react-native-appsflyer=$RC_VERSION" + echo "Pinned react-native-appsflyer to $RC_VERSION" + grep "react-native-appsflyer" package.json + + - name: Write .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: printf '%s\n' "$ENV_FILE" > example_rc_smoke/.env + + - name: Install npm RC + working-directory: example_rc_smoke + run: | + set -euo pipefail + ATTEMPTS=5 + SLEEP=30 + for i in $(seq 1 "$ATTEMPTS"); do + echo "npm install attempt $i/$ATTEMPTS" + if npm install; then + echo "npm install succeeded on attempt $i" + break + fi + if [[ "$i" == "$ATTEMPTS" ]]; then + echo "npm install failed after $ATTEMPTS attempts" >&2 + exit 1 + fi + npm cache clean --force || true + sleep "$SLEEP" + done + + - name: Build Android APK and run smoke on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 33 + arch: x86_64 + profile: pixel_6 + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -dns-server 8.8.8.8,1.1.1.1 + disable-animations: true + script: | + set -e + adb shell 'getprop net.dns1; getprop net.dns2' || true + adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } + cd example_rc_smoke && npx react-native build-android --mode=debug && cd .. + ./scripts/af-scenario-runner.sh --platform android --plan .af-smoke/rc-test-plan.json + + - name: Upload smoke reports + if: always() + uses: actions/upload-artifact@v5 + with: + name: rc-smoke-android-${{ github.run_number }} + path: .af-smoke/reports/ + retention-days: 30 + + # =========================================================================== + # Post the rc-smoke/npm check-run on the release branch head SHA. This is + # the gate promote-release.yml verifies before stripping -rcN. + # =========================================================================== + post-check-run: + name: Post rc-smoke/npm check-run + needs: [resolve, smoke-ios, smoke-android] + if: always() && needs.resolve.outputs.should_run == 'true' && needs.resolve.outputs.head_sha != '' + runs-on: ubuntu-latest + steps: + - name: Post check-run + uses: actions/github-script@v7 + env: + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + RC_VERSION: ${{ needs.resolve.outputs.rc_version }} + IOS_RESULT: ${{ needs.smoke-ios.result }} + ANDROID_RESULT: ${{ needs.smoke-android.result }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const { HEAD_SHA, RC_VERSION, IOS_RESULT, ANDROID_RESULT, RUN_URL } = process.env; + const bothGreen = IOS_RESULT === 'success' && ANDROID_RESULT === 'success'; + const conclusion = bothGreen ? 'success' : 'failure'; + const summary = `rc-smoke on npm RC \`${RC_VERSION}\`\n\n` + + `- iOS: **${IOS_RESULT}**\n` + + `- Android: **${ANDROID_RESULT}**\n\n` + + `Reports: ${RUN_URL}`; + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'rc-smoke/npm', + head_sha: HEAD_SHA, + status: 'completed', + conclusion, + details_url: RUN_URL, + output: { + title: bothGreen ? 'rc-smoke PASS' : 'rc-smoke FAIL', + summary + } + }); + + # =========================================================================== + # Notify Slack on a real smoke failure. + # =========================================================================== + notify-failure: + name: Notify Smoke Failure + needs: [resolve, smoke-ios, smoke-android] + if: >- + always() && + needs.resolve.outputs.should_run == 'true' && + (needs.smoke-ios.result == 'failure' || needs.smoke-android.result == 'failure' || + needs.smoke-ios.result == 'cancelled' || needs.smoke-android.result == 'cancelled') + runs-on: ubuntu-latest + steps: + - name: Send Slack failure notification + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "text": "\n:warning: *React Native rc-smoke failed for `${{ needs.resolve.outputs.rc_version }}`*\n\nThe RC is published on npm but smoke checks did not pass on the artifact. Production promotion is blocked.\n\n*Smoke results:*\n- iOS: ${{ needs.smoke-ios.result }}\n- Android: ${{ needs.smoke-android.result }}\n\n*Branch:* ${{ needs.resolve.outputs.release_branch }}\n*Run:* https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Next step:* Bump to `rcN+1` and rerun the RC-release workflow with `dry_run=false`. Republishing the same RC version is not supported by npm." + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + + # =========================================================================== + # Post a skipped check-run when the parent run was dry, the version is not + # on npm, or the parent did not succeed. + # =========================================================================== + post-skipped-check: + name: Post rc-smoke/npm (skipped) + needs: [resolve] + if: needs.resolve.outputs.should_run == 'false' && needs.resolve.outputs.head_sha != '' + runs-on: ubuntu-latest + steps: + - name: Post skipped check-run + uses: actions/github-script@v7 + env: + HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} + SKIP_REASON: ${{ needs.resolve.outputs.skip_reason }} + RC_VERSION: ${{ needs.resolve.outputs.rc_version }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const { HEAD_SHA, SKIP_REASON, RC_VERSION, RUN_URL } = process.env; + const reasonLabels = { + parent_not_success: 'Parent RC-release run did not succeed', + not_rc_version: 'Head branch version is not an RC', + not_on_npm: 'RC not yet on npm (dry run or indexing delay)' + }; + await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'rc-smoke/npm', + head_sha: HEAD_SHA, + status: 'completed', + conclusion: 'skipped', + details_url: RUN_URL, + output: { + title: 'rc-smoke skipped', + summary: `Skipped: ${reasonLabels[SKIP_REASON] || SKIP_REASON}\n\n` + + `Version: \`${RC_VERSION || '(unknown)'}\`\n\n` + + `Run: ${RUN_URL}\n\n` + + `promote-release.yml does not accept \`skipped\` as a green gate; if this was a dry run, start a new RC-release with \`dry_run=false\` to publish and re-smoke.` + } + }); diff --git a/.github/workflows/release-Production-workflow.yml b/.github/workflows/release-Production-workflow.yml deleted file mode 100644 index af505cc1..00000000 --- a/.github/workflows/release-Production-workflow.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Release plugin to production - -on: - pull_request: - types: - - closed - branches: - - 'master' - paths-ignore: - - '**.md' - - '**.yml' - - 'demoes/**' - - 'Docs/**' -jobs: - Deploy-To-Production: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Login to Github - env: - COMMIT_AUTHOR: ${{ secrets.CI_COMMIT_AUTHOR }} - COMMIT_EMAIL: ${{ secrets.CI_COMMIT_EMAIL }} - run: | - git config --global user.name $COMMIT_AUTHOR - git config --global user.email $COMMIT_EMAIL - - - uses: mdecoleman/pr-branch-name@1.2.0 - id: vars - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Determine release tag and release branch - run: | - TAG=$(echo "${{ steps.vars.outputs.branch }}" | grep -Eo '[0-9].[0-9]+.[0-9]+') - echo "PLUGIN_VERSION=$TAG" >> $GITHUB_ENV - echo "RELEASE_BRANCH_NAME=${{ steps.vars.outputs.branch }}" >> $GITHUB_ENV - echo "push new release >> $TAG" - - - name: "Create release" - env: - TAG: ${{env.PLUGIN_VERSION}} - uses: "actions/github-script@v5" - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - script: | - try { - await github.rest.repos.createRelease({ - draft: false, - generate_release_notes: false, - name: process.env.TAG, - owner: context.repo.owner, - prerelease: false, - repo: context.repo.repo, - tag_name: process.env.TAG - }); - } catch (error) { - core.setFailed(error.message); - } - - - name: Push to NPM - env: - CI_NPM_TOKEN: ${{ secrets.CI_NPM_TOKEN }} - run: | - echo "//registry.npmjs.org/:_authToken=$CI_NPM_TOKEN" > ~/.npmrc - npm publish - - - - name: Generate and send slack report - env: - SLACK_TOKEN: ${{ secrets.CI_SLACK_TOKEN }} - JIRA_TOKEN: ${{ secrets.CI_JIRA_TOKEN }} - JIRA_FIXED_VERSION: "React Native SDK v${{env.PLUGIN_VERSION}}" - RELEASE_BRACH_NAME: ${{env.RELEASE_BRANCH_NAME}} - run: | - chmod +x .github/workflows/scripts/releaseNotesGenerator.sh - .github/workflows/scripts/releaseNotesGenerator.sh $JIRA_TOKEN "$JIRA_FIXED_VERSION" - ios_sdk_version=$(cat react-native-appsflyer.podspec | grep '\'AppsFlyerFramework\' | grep -Eo '[0-9].[0-9]+.[0-9]+') - android_sdk_version=$(cat android/build.gradle | grep 'com.appsflyer:af-android-sdk' | grep -Eo '[0-9].[0-9]+.[0-9]+') - CHANGES=$(cat "$JIRA_FIXED_VERSION-releasenotes".txt) - curl -X POST -H 'Content-type: application/json' --data '{"jira_fixed_version": "'"${{env.JIRA_FIXED_VERSION}}"'", "deploy_type": "Production", "install_tag": "latest", "git_branch": "'"$RELEASE_BRACH_NAME"'", "changes_and_fixes": "'"$CHANGES"'", "android_dependencie": "'"$android_sdk_version"'", "ios_dependencie": "'"$ios_sdk_version"'"}' "$SLACK_TOKEN" diff --git a/.github/workflows/release-QA-workflow.yml b/.github/workflows/release-QA-workflow.yml deleted file mode 100644 index 84bfa9d2..00000000 --- a/.github/workflows/release-QA-workflow.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Release plugin to QA - -on: - push: - branches: - - releases/[0-9].x.x/[0-9].[0-9]+.x/[0-9].[0-9]+.[0-9]+-rc[0-9]+ - -jobs: - Run-Unit-Tests: - uses: ./.github/workflows/unit-tests-workflow.yml - - Build-Sample-Apps: - uses: ./.github/workflows/build-apps-workflow.yml - secrets: inherit - - Deploy-To-QA: - needs: [Run-Unit-Tests, Build-Sample-Apps] - uses: ./.github/workflows/deploy-to-QA.yml - secrets: inherit \ No newline at end of file diff --git a/.github/workflows/scripts/archiveApp.sh b/.github/workflows/scripts/archiveApp.sh deleted file mode 100755 index 244dc288..00000000 --- a/.github/workflows/scripts/archiveApp.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -eo pipefail - -xcodebuild -workspace AppsFlyerExample.xcworkspace \ - -scheme AppsFlyerExample \ - -sdk iphoneos \ - -allowProvisioningUpdates \ - -archivePath $PWD/build/AppsFlyerExample.xcarchive \ - clean archive | xcpretty \ No newline at end of file diff --git a/.github/workflows/scripts/decryptSecrets.sh b/.github/workflows/scripts/decryptSecrets.sh deleted file mode 100755 index 61e316e0..00000000 --- a/.github/workflows/scripts/decryptSecrets.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -set -eo pipefail - -gpg --quiet --batch --yes --decrypt --passphrase="$IOS_KEYS" --output ./.github/secrets/GithubCIApp.mobileprovision.mobileprovision ./.github/secrets/GithubCIApp.mobileprovision.gpg -gpg --quiet --batch --yes --decrypt --passphrase="$IOS_KEYS" --output ./.github/secrets/GithubCICer.p12 ./.github/secrets/GithubCICer.p12.gpg - -mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - -cp ./.github/secrets/GithubCIApp.mobileprovision.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/GithubCIApp.mobileprovision.mobileprovision - - -security create-keychain -p "$IOS_KEYS" build.keychain -security import ./.github/secrets/GithubCICer.p12 -t agg -k ~/Library/Keychains/build.keychain -P "$IOS_KEYS" -A - -security list-keychains -s ~/Library/Keychains/build.keychain -security default-keychain -s ~/Library/Keychains/build.keychain -security unlock-keychain -p "$IOS_KEYS" ~/Library/Keychains/build.keychain - -security set-key-partition-list -S apple-tool:,apple: -s -k "$IOS_KEYS" ~/Library/Keychains/build.keychain \ No newline at end of file diff --git a/.github/workflows/scripts/releaseNotesGenerator.sh b/.github/workflows/scripts/releaseNotesGenerator.sh deleted file mode 100755 index 92a976ab..00000000 --- a/.github/workflows/scripts/releaseNotesGenerator.sh +++ /dev/null @@ -1,19 +0,0 @@ -JIRA_TOKEN=$1 -JIRA_FIXED_VERSION=$2 - -fixed_version_found=false -curl -X GET https://appsflyer.atlassian.net/rest/api/3/project/11723/versions --user $JIRA_TOKEN | jq -r '.[] | .name+""+.id' | while read version ; do -if [[ "$version" == *"$JIRA_FIXED_VERSION"* ]] ;then - echo "$JIRA_FIXED_VERSION Found!" - fixed_version_found=true - version_id=${version#"$JIRA_FIXED_VERSION"} - echo $(curl -X GET https://appsflyer.atlassian.net/rest/api/3/search?jql=fixVersion=$version_id --user $JIRA_TOKEN | jq -r '.issues[] | "- " + .fields["summary"]+"@"') > "$JIRA_FIXED_VERSION-releasenotes".txt - sed -i -r -e "s/@ /\n/gi" "$JIRA_FIXED_VERSION-releasenotes".txt - sed -i -r -e "s/@/\n/gi" "$JIRA_FIXED_VERSION-releasenotes".txt - cat "$JIRA_FIXED_VERSION-releasenotes".txt -fi -done -if [ fixed_version_found == false ];then -echo "$JIRA_FIXED_VERSION is not found!" -exit 1 -fi \ No newline at end of file diff --git a/.github/workflows/scripts/updateReadme.sh b/.github/workflows/scripts/updateReadme.sh deleted file mode 100755 index 23c706e3..00000000 --- a/.github/workflows/scripts/updateReadme.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -ios_sdk_version=$(cat react-native-appsflyer.podspec | grep '\'AppsFlyerFramework\' | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+') -android_sdk_version=$(cat android/build.gradle | grep 'com.appsflyer:af-android-sdk' | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+') -sed -i -r "s/Android AppsFlyer SDK \*\*v[0-9]+\.[0-9]+\.[0-9]+\*\*/Android AppsFlyer SDK \*\*v$android_sdk_version\*\*/g" README.md -sed -i -r "s/iOS AppsFlyer SDK \*\*v[0-9]+\.[0-9]+\.[0-9]+\*\*/iOS AppsFlyer SDK \*\*v$ios_sdk_version\*\*/g" README.md diff --git a/.github/workflows/scripts/versionsAlignmentValidator.sh b/.github/workflows/scripts/versionsAlignmentValidator.sh deleted file mode 100755 index 06ff83a6..00000000 --- a/.github/workflows/scripts/versionsAlignmentValidator.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# Plugin version from release branch -PLUGIN_VERSION=$1 - -# Gets the plugin version for platform_extension_v2 in every platform -IOS_PLUGIN_VERSION=$(cat ios/RNAppsFlyer.h | grep 'kAppsFlyerPluginVersion' | grep -Eo '[0-9].[0-9]+.[0-9]') -ANDROID_PLUGIN_VERSION=$(cat android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java | grep 'PLUGIN_VERSION' | grep -Eo '[0-9].[0-9]+.[0-9]') - -# Check if PLUGIN_VERSION, IOS_PLUGIN_VERSION and ANDROID_PLUGIN_VERSION are equal -echo "version from branch: $PLUGIN_VERSION\nplatform_extension_v2 ios: $IOS_PLUGIN_VERSION\nplatform_extension_v2 android: $ANDROID_PLUGIN_VERSION" -if [[ "$PLUGIN_VERSION" == "$IOS_PLUGIN_VERSION" && "$PLUGIN_VERSION" == "$ANDROID_PLUGIN_VERSION" ]]; then - echo "platform_extension_v2 version is aligned" -else - echo "platform_extension_v2 version is different!" - exit 1 -fi \ No newline at end of file diff --git a/.github/workflows/unit-tests-workflow.yml b/.github/workflows/unit-tests-workflow.yml deleted file mode 100644 index 35ca5ff5..00000000 --- a/.github/workflows/unit-tests-workflow.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Tests - -on: - pull_request: - push: - branches-ignore: - - 'releases/**' - - 'master' - workflow_call: - -jobs: - Run-unit-tests: - if: ${{ startsWith(github.head_ref, 'releases/') == false }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Install modules - run: npm install --legacy-peer-deps - - name: Run jest tests - run: npm run test \ No newline at end of file From 73ee38d115c2b4d6cc4278910eb095f2940b3d05 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 12 May 2026 15:11:22 +0300 Subject: [PATCH 04/21] Add E2E scenario runner and test plans for iOS and Android --- .af-e2e/test-plan.json | 314 +++++++++++ .af-smoke/rc-test-plan.json | 175 ++++++ scripts/af-scenario-runner.sh | 994 ++++++++++++++++++++++++++++++++++ 3 files changed, 1483 insertions(+) create mode 100644 .af-e2e/test-plan.json create mode 100644 .af-smoke/rc-test-plan.json create mode 100755 scripts/af-scenario-runner.sh diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json new file mode 100644 index 00000000..c6c1816f --- /dev/null +++ b/.af-e2e/test-plan.json @@ -0,0 +1,314 @@ +{ + "_meta": { + "plan_id": "reactnative-e2e", + "plugin": "reactnative", + "version": "1.0.0", + "description": "Pre-publish E2E test plan for the AppsFlyer React Native plugin. Runs against plugin source in example/. Covers six scenarios: cold launch, background deep link, foreground deep link, custom events, identity APIs, and consent/stop. Mapped to E2E-001..E2E-006 in appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.", + "platforms": ["android", "ios"], + "schema_version": "1.0.0", + "tooling_contract_ref": "E2E-001, E2E-002, E2E-003, E2E-004, E2E-005, E2E-006" + }, + + "config": { + "android": { + "package_name": "com.appsflyer.qa.reactnative", + "activity": ".MainActivity", + "apk_path": "example/android/app/build/outputs/apk/debug/app-debug.apk", + "build_cmd": "cd example/android && ./gradlew assembleDebug" + }, + "ios": { + "bundle_id": "com.appsflyer.qa.reactnative", + "app_path": "example/ios/build/Build/Products/Debug-iphonesimulator/example.app", + "build_cmd": "cd example/ios && xcodebuild build -workspace example.xcworkspace -scheme example -configuration Debug -destination 'platform=iOS Simulator,id=$IOS_SIMULATOR_UDID' -derivedDataPath build" + } + }, + + "phases": [ + { + "id": "phase_1", + "name": "Cold launch coverage", + "scenario_ref": "E2E-001", + "description": "Fresh install. Validate SDK startup, pre/post-start APIs, three auto-launched events, and install conversion data callback.", + "requires_fresh_install": true, + "wait_after_launch_sec": 30, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns a result", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "id": "pre_start_complete", + "description": "Pre-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "post_start_complete", + "description": "Post-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "conversion_data", + "description": "onInstallConversionData callback fires", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "fail_action": "warn" + }, + { + "id": "events_fired", + "description": "At least 3 logEvent calls recorded", + "type": "count_matches", + "pattern": "\\[AF_QA\\]\\[logEvent", + "minimum": 3, + "fail_action": "fail" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions or process crashes in logs", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_2", + "name": "Background deep link", + "scenario_ref": "E2E-002", + "description": "App backgrounded after Phase 1, then deep link triggers re-entry. onDeepLinking fires with the expected deep link value.", + "requires_fresh_install": false, + "wait_after_trigger_sec": 15, + "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": ["adb shell input keyevent KEYCODE_HOME", "sleep 2"], + "ios": ["xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", "sleep 2", "xcrun simctl launch {{UDID}} {{BUNDLE_ID}}", "sleep 3"] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl openurl {{UDID}} \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "deep_link_received", + "description": "onDeepLinking callback fires", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "fail_action": "warn" + }, + { + "id": "deep_link_value", + "description": "Deep link value matches qa_deeplink_bg", + "type": "log_contains", + "pattern": "qa_deeplink_bg", + "fail_action": "warn" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions after deep link", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_3", + "name": "Foreground deep link", + "scenario_ref": "E2E-003", + "description": "Fresh install, SDK starts, then deep link while app is in foreground. Verifies both SDK start and deep link callback fire correctly.", + "requires_fresh_install": true, + "wait_after_launch_sec": 30, + "wait_after_trigger_sec": 15, + "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": ["adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME", "sleep 1"], + "ios": ["sleep 3"] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl openurl {{UDID}} \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns a result on fresh install", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "id": "deep_link_received", + "description": "onDeepLinking callback fires after foreground deep link", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "fail_action": "warn" + }, + { + "id": "deep_link_value", + "description": "Deep link value matches qa_deeplink_fg", + "type": "log_contains", + "pattern": "qa_deeplink_fg", + "fail_action": "warn" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_4", + "name": "Custom events", + "scenario_ref": "E2E-004", + "description": "Validates logEvent API with multiple event types and params. Verifies af_demo_launch, af_purchase, and af_content_view all return success.", + "requires_fresh_install": false, + "wait_after_launch_sec": 30, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns a result", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "id": "event_demo_launch", + "description": "af_demo_launch event fires successfully", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_demo_launch)] result:", + "fail_action": "fail" + }, + { + "id": "event_purchase", + "description": "af_purchase event fires successfully", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_purchase)] result:", + "fail_action": "fail" + }, + { + "id": "event_content_view", + "description": "af_content_view event fires successfully", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_content_view)] result:", + "fail_action": "fail" + }, + { + "id": "events_count", + "description": "At least 3 logEvent calls recorded", + "type": "count_matches", + "pattern": "\\[AF_QA\\]\\[logEvent", + "minimum": 3, + "fail_action": "fail" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions or process crashes", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_5", + "name": "Identity APIs", + "scenario_ref": "E2E-005", + "description": "Validates setCustomerUserId, getAppsFlyerUID, and getSDKVersion readbacks after SDK start.", + "requires_fresh_install": false, + "wait_after_launch_sec": 30, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns a result", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "id": "customer_user_id_set", + "description": "setCustomerUserId readback present", + "type": "log_contains", + "pattern": "[AF_QA][setCustomerUserId] result:", + "fail_action": "fail" + }, + { + "id": "appsflyer_uid", + "description": "getAppsFlyerUID returns a value", + "type": "log_contains", + "pattern": "[AF_QA][getAppsFlyerUID] result:", + "fail_action": "fail" + }, + { + "id": "sdk_version", + "description": "getSDKVersion returns a value", + "type": "log_contains", + "pattern": "[AF_QA][getSDKVersion] result:", + "fail_action": "fail" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions or process crashes", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_6", + "name": "Consent and sharing", + "scenario_ref": "E2E-006", + "description": "Validates setSharingFilterForPartners and setConsentData readbacks. stop(true)/stop(false) cycle requires HTTP traffic inspection (future).", + "requires_fresh_install": false, + "wait_after_launch_sec": 30, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns a result", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "id": "sharing_filter", + "description": "setSharingFilterForPartners readback present", + "type": "log_contains", + "pattern": "[AF_QA][setSharingFilterForPartners] result: [partner_test]", + "fail_action": "fail" + }, + { + "id": "consent_data", + "description": "setConsentData GDPR consent readback present", + "type": "log_contains", + "pattern": "[AF_QA][setConsentData] result: GDPR consent set", + "fail_action": "fail" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions or process crashes", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + } + ], + + "report": { + "output_dir": ".af-e2e/reports" + } +} diff --git a/.af-smoke/rc-test-plan.json b/.af-smoke/rc-test-plan.json new file mode 100644 index 00000000..0ef45db4 --- /dev/null +++ b/.af-smoke/rc-test-plan.json @@ -0,0 +1,175 @@ +{ + "_meta": { + "plan_id": "reactnative-rc-smoke", + "plugin": "reactnative", + "version": "1.0.0", + "description": "Post-publish smoke plan for the AppsFlyer React Native plugin. Exercises SMOKE-001/002/003 against example_rc_smoke/, which pins react-native-appsflyer@ from npm. This plan never depends on plugin source.", + "platforms": ["android", "ios"], + "schema_version": "1.0.0", + "tooling_contract_ref": "SMOKE-001, SMOKE-002, SMOKE-003" + }, + + "config": { + "android": { + "package_name": "com.appsflyer.qa.reactnative", + "activity": ".MainActivity", + "apk_path": "example_rc_smoke/android/app/build/outputs/apk/debug/app-debug.apk", + "build_cmd": "cd example_rc_smoke/android && ./gradlew assembleDebug" + }, + "ios": { + "bundle_id": "com.appsflyer.qa.reactnative", + "app_path": "example_rc_smoke/ios/build/Build/Products/Debug-iphonesimulator/example.app", + "build_cmd": "cd example_rc_smoke/ios && xcodebuild build -workspace example.xcworkspace -scheme example -configuration Debug -destination 'platform=iOS Simulator,id=$IOS_SIMULATOR_UDID' -derivedDataPath build" + } + }, + + "phases": [ + { + "id": "phase_1", + "name": "Cold launch smoke (RC artifact)", + "scenario_ref": "SMOKE-001", + "description": "Fresh install using the npm-pinned RC build. Validates SDK startup, install conversion data, pre/post-start API markers, and standard events.", + "requires_fresh_install": true, + "wait_after_launch_sec": 30, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns a result", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "id": "conversion_data", + "description": "onInstallConversionData callback fires", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "fail_action": "fail" + }, + { + "id": "pre_start_complete", + "description": "Pre-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "post_start_complete", + "description": "Post-start auto APIs ran", + "type": "log_contains", + "pattern": "[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---", + "fail_action": "fail" + }, + { + "id": "events_fired", + "description": "At least 3 logEvent calls recorded", + "type": "count_matches", + "pattern": "\\[AF_QA\\]\\[logEvent", + "minimum": 3, + "fail_action": "fail" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions or process crashes", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_2", + "name": "Background deep link (RC artifact)", + "scenario_ref": "SMOKE-002", + "description": "Backgrounds the RC build after Phase 1. Deep link returns app to foreground. onDeepLinking fires with expected deep link value.", + "requires_fresh_install": false, + "wait_after_trigger_sec": 15, + "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": ["adb shell input keyevent KEYCODE_HOME", "sleep 2"], + "ios": ["xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", "sleep 1"] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl launch {{UDID}} {{BUNDLE_ID}} -deepLinkURL \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "deep_link_received", + "description": "onDeepLinking callback fires", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "fail_action": "fail" + }, + { + "id": "deep_link_value", + "description": "Deep link value matches qa_deeplink_bg", + "type": "log_contains", + "pattern": "qa_deeplink_bg", + "fail_action": "fail" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions after deep link", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_3", + "name": "Foreground deep link (RC artifact)", + "scenario_ref": "SMOKE-003", + "description": "Fresh install of the RC build, then deep link while app is in foreground. Verifies SDK start and deep link callback.", + "requires_fresh_install": true, + "wait_after_launch_sec": 30, + "wait_after_trigger_sec": 15, + "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", + "pre_actions": { + "android": ["adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME", "sleep 1"], + "ios": ["xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", "sleep 1"] + }, + "trigger": { + "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", + "ios": "xcrun simctl launch {{UDID}} {{BUNDLE_ID}} -deepLinkURL \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK returns a result on fresh install", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "id": "deep_link_received", + "description": "onDeepLinking callback fires after foreground deep link", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "fail_action": "fail" + }, + { + "id": "deep_link_value", + "description": "Deep link value matches qa_deeplink_fg", + "type": "log_contains", + "pattern": "qa_deeplink_fg", + "fail_action": "fail" + }, + { + "id": "no_fatal", + "description": "No fatal exceptions", + "type": "absent", + "patterns": ["FATAL EXCEPTION", "Process crashed"], + "fail_action": "fail" + } + ] + } + ], + + "report": { + "output_dir": ".af-smoke/reports" + } +} diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh new file mode 100755 index 00000000..7b84cb5c --- /dev/null +++ b/scripts/af-scenario-runner.sh @@ -0,0 +1,994 @@ + +#!/usr/bin/env bash +# +# af-scenario-runner.sh — Unified AppsFlyer plugin scenario runner +# +# Drives a JSON-driven scenario cycle for any AppsFlyer plugin using ADB +# (Android) and xcrun simctl (iOS). Reads a test plan, executes each phase, +# validates log output against expected patterns, and produces a structured +# JSON report. Used for both pre-publish E2E (.af-e2e/test-plan.json) and +# post-publish smoke (.af-smoke/rc-test-plan.json) — the runner is the same; +# the plan is what differs. +# +# Usage: +# ./af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json +# ./af-scenario-runner.sh --platform ios --plan .af-smoke/rc-test-plan.json +# ./af-scenario-runner.sh --platform android --plan --phase phase_1 +# ./af-scenario-runner.sh --platform android --plan --dry-run +# ./af-scenario-runner.sh --platform android --plan --build +# +# Requirements: +# - bash 4+, jq +# - Android: ADB in PATH, emulator booted +# - iOS: Xcode CLI tools, simulator booted +# +# The script is agent-agnostic: any AI coding assistant (Cursor, Claude Code, +# GitHub Copilot, Windsurf) or a human can invoke it from a terminal. + + + +set -euo pipefail + +# Bash 5.2+ enables patsub_replacement by default, which makes `&` in the +# replacement string of ${var//pat/repl} expand to the matched pattern (sed +# style). That mangles deep link URLs like `?a=1&b=2` into `?a=1{{DEEP_LINK_URL}}b=2` +# when we substitute the URL into trigger templates below. Disable it so +# replacements are taken literally on every supported runner (macOS bash 3.2, +# Ubuntu bash 5.2+, etc.). +shopt -u patsub_replacement 2>/dev/null || true + +# ─── Defaults ──────────────────────────────────────────────────────────────── + +PLATFORM="" +PLAN_FILE="" +PHASE_FILTER="" +DRY_RUN=false +BUILD_FIRST=false +VERBOSE=false +REPORT_DIR="" +LOG_TAG="AF_QA" + +# Timestamps +RUN_ID="" +RUN_START="" + +# Counters +TOTAL_CHECKS=0 +PASSED_CHECKS=0 +FAILED_CHECKS=0 +WARNED_CHECKS=0 +ABORTED=false + +# ─── Colors ────────────────────────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# ─── Usage ─────────────────────────────────────────────────────────────────── + +usage() { + cat < Target platform (required) + --plan Path to test-plan.json (required) + --phase Run only this phase (optional; runs all if omitted) + --build Build the app before running (optional) + --dry-run Show what would run without executing (optional) + --verbose Print extra debug output (optional) + --report-dir Override report output directory (optional) + -h, --help Show this help + +Examples: + $(basename "$0") --platform android --plan .af-e2e/test-plan.json + $(basename "$0") --platform ios --plan .af-smoke/rc-test-plan.json --phase phase_1 + $(basename "$0") --platform android --plan .af-e2e/test-plan.json --build --verbose +EOF + exit 0 +} + +# ─── Logging helpers ───────────────────────────────────────────────────────── + +log_info() { echo -e "${CYAN}[INFO]${NC} $*" >&2; } +log_ok() { echo -e "${GREEN}[PASS]${NC} $*" >&2; } +log_fail() { echo -e "${RED}[FAIL]${NC} $*" >&2; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } +log_step() { echo -e "${BOLD}────── $* ──────${NC}" >&2; } +log_debug() { if $VERBOSE; then echo -e "[DEBUG] $*" >&2; fi; } + +# ─── Argument parsing ──────────────────────────────────────────────────────── + +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) PLATFORM="$2"; shift 2 ;; + --plan) PLAN_FILE="$2"; shift 2 ;; + --phase) PHASE_FILTER="$2"; shift 2 ;; + --build) BUILD_FIRST=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --verbose) VERBOSE=true; shift ;; + --report-dir) REPORT_DIR="$2"; shift 2 ;; + -h|--help) usage ;; + *) echo "Unknown option: $1"; usage ;; + esac +done + +[[ -z "$PLATFORM" ]] && { echo "Error: --platform is required"; usage; } +[[ -z "$PLAN_FILE" ]] && { echo "Error: --plan is required"; usage; } +[[ ! -f "$PLAN_FILE" ]] && { echo "Error: Plan file not found: $PLAN_FILE"; exit 1; } + +PLAN_DIR="$(cd "$(dirname "$PLAN_FILE")" && pwd)" +PROJECT_ROOT="$(cd "$PLAN_DIR/.." && pwd)" + +# Validate platform +case "$PLATFORM" in + android|ios) ;; + *) echo "Error: --platform must be 'android' or 'ios'"; exit 1 ;; +esac + +# Check dependencies +command -v jq >/dev/null 2>&1 || { echo "Error: jq is required but not installed. Install with: brew install jq"; exit 1; } + +if [[ "$PLATFORM" == "android" ]]; then + command -v adb >/dev/null 2>&1 || { echo "Error: adb not found in PATH"; exit 1; } +elif [[ "$PLATFORM" == "ios" ]]; then + command -v xcrun >/dev/null 2>&1 || { echo "Error: xcrun not found (install Xcode CLI tools)"; exit 1; } +fi + +# ─── Read plan ─────────────────────────────────────────────────────────────── + +PLAN=$(cat "$PLAN_FILE") +PLAN_ID=$(echo "$PLAN" | jq -r '._meta.plan_id // "unknown"') +PLUGIN_NAME=$(echo "$PLAN" | jq -r '._meta.plugin // "unknown"') + +# Platform-specific config +PACKAGE_NAME=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.package_name // .config.${PLATFORM}.bundle_id // \"\"") +APP_PATH=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.apk_path // .config.${PLATFORM}.app_path // \"\"") +BUILD_CMD=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.build_cmd // \"\"") +ACTIVITY=$(echo "$PLAN" | jq -r ".config.${PLATFORM}.activity // \"\"") + +# Resolve relative paths against project root +if [[ -n "$APP_PATH" && "${APP_PATH:0:1}" != "/" ]]; then + APP_PATH="${PROJECT_ROOT}/${APP_PATH}" +fi + +# Report directory +if [[ -z "$REPORT_DIR" ]]; then + REPORT_DIR=$(echo "$PLAN" | jq -r '.report.output_dir // ".af-smoke/reports/"') +fi +if [[ "${REPORT_DIR:0:1}" != "/" ]]; then + REPORT_DIR="${PROJECT_ROOT}/${REPORT_DIR}" +fi + +# Generate run ID +RUN_START=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +RUN_ID="${PLAN_ID}-${PLATFORM}-$(date +%Y%m%d_%H%M%S)" + +log_info "Plan: ${PLAN_ID} | Plugin: ${PLUGIN_NAME} | Platform: ${PLATFORM}" +log_info "Package: ${PACKAGE_NAME}" +log_info "Run ID: ${RUN_ID}" + +if $DRY_RUN; then + log_warn "DRY RUN MODE — no commands will be executed" +fi + +# ─── Setup report directory ────────────────────────────────────────────────── + +mkdir -p "$REPORT_DIR" +REPORT_FILE="${REPORT_DIR}/${RUN_ID}.json" +PHASE_RESULTS="[]" + +# ─── Platform helpers ──────────────────────────────────────────────────────── + +# --- Android --- + +android_get_device() { + adb devices | grep -w "device" | head -1 | awk '{print $1}' +} + +android_is_installed() { + adb shell pm list packages 2>/dev/null | grep -q "$PACKAGE_NAME" +} + +android_uninstall() { + log_info "Uninstalling $PACKAGE_NAME..." + if android_is_installed; then + adb uninstall "$PACKAGE_NAME" 2>/dev/null || true + else + log_info "App not installed, skipping uninstall" + fi +} + +android_install() { + log_info "Installing $APP_PATH..." + if [[ ! -f "$APP_PATH" ]]; then + log_fail "APK not found at $APP_PATH" + if [[ -n "$BUILD_CMD" ]]; then + log_info "Hint: run with --build to build first, or manually: $BUILD_CMD" + fi + return 1 + fi + adb install -r "$APP_PATH" +} + +android_launch() { + log_info "Launching $PACKAGE_NAME..." + adb logcat -c + adb shell am start -n "${PACKAGE_NAME}/${ACTIVITY}" 2>/dev/null || \ + adb shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1 2>/dev/null +} + +android_get_pid() { + adb shell pidof "$PACKAGE_NAME" 2>/dev/null | tr -d '[:space:]' +} + +android_collect_logs() { + local log_file="$1" + local tail_lines="${ANDROID_LOGCAT_TAIL_LINES:-2000}" + + # Always start from an empty file so each phase capture is self-contained. + : > "$log_file" + + # Strategy 1: Read the app's af_qa_logs.txt from internal storage via + # `run-as`. Required because Flutter debug APKs launched standalone (no + # `flutter run` host) do not forward Dart `debugPrint` to logcat, so the + # file is the only reliable source of [AF_QA] markers. The Documents dir + # path on Android is `app_flutter/` for path_provider, but newer versions + # may write directly under `files/`, so try both. `run-as` works because + # `flutter build apk --debug` produces a debuggable APK. + local found=0 + for path in app_flutter/af_qa_logs.txt files/af_qa_logs.txt; do + if adb shell "run-as $PACKAGE_NAME cat $path 2>/dev/null" >> "$log_file" 2>/dev/null; then + if [[ -s "$log_file" ]]; then + log_debug "Pulled Android QA log from $path" + found=1 + break + fi + fi + done + if [[ "$found" -eq 0 ]]; then + log_debug "No af_qa_logs.txt found via run-as; relying on logcat only" + fi + + # Strategy 2: Always also append logcat output. AppsFlyer SDK native logs + # (HTTP response codes, etc.) reach logcat regardless of the Dart-print + # routing, and the count_matches checks need them. Limit to the recent tail + # so CI does not spend a minute dumping the whole emulator buffer every phase. + adb logcat -d -t "$tail_lines" 2>&1 | grep -E "${LOG_TAG}|AppsFlyer|response code:|preparing data:" >> "$log_file" || true +} + +android_background_app() { + log_info "Backgrounding app (HOME key)..." + adb shell input keyevent KEYCODE_HOME +} + +android_trigger_deeplink() { + local url="$1" + log_info "Triggering deep link: $url" + adb shell am start -W -a android.intent.action.VIEW -d "$url" +} + +android_is_alive() { + local pid + pid=$(android_get_pid) + [[ -n "$pid" ]] +} + +# --- iOS --- + +IOS_UDID="" +IOS_LAST_PID="" + +ios_get_booted_udid() { + xcrun simctl list devices booted -j 2>/dev/null | \ + jq -r '[.devices[][] | select(.state == "Booted")] | first | .udid // empty' +} + +ios_ensure_udid() { + if [[ -z "$IOS_UDID" ]]; then + IOS_UDID=$(ios_get_booted_udid) + if [[ -z "$IOS_UDID" ]]; then + log_fail "No booted iOS simulator found. Boot one with: xcrun simctl boot " + exit 1 + fi + log_info "Using simulator: $IOS_UDID" + fi +} + +ios_is_installed() { + xcrun simctl listapps "$IOS_UDID" 2>/dev/null | grep -q "$PACKAGE_NAME" 2>/dev/null +} + +ios_uninstall() { + ios_ensure_udid + log_info "Uninstalling $PACKAGE_NAME..." + if ios_is_installed; then + xcrun simctl uninstall "$IOS_UDID" "$PACKAGE_NAME" 2>/dev/null || true + else + log_info "App not installed, skipping uninstall" + fi +} + +ios_install() { + ios_ensure_udid + log_info "Installing $APP_PATH..." + if [[ ! -d "$APP_PATH" ]]; then + log_fail "App bundle not found at $APP_PATH" + if [[ -n "$BUILD_CMD" ]]; then + log_info "Hint: run with --build to build first, or manually: $BUILD_CMD" + fi + return 1 + fi + xcrun simctl install "$IOS_UDID" "$APP_PATH" +} + +ios_launch() { + ios_ensure_udid + log_info "Launching $PACKAGE_NAME..." + # Capture launch output so we can pin log filtering to this PID. simctl + # prints ": " on success; anything else (already running, + # error) leaves IOS_LAST_PID empty and the collector falls back to the + # unfiltered window. + local out + out=$(xcrun simctl launch "$IOS_UDID" "$PACKAGE_NAME" 2>&1 || true) + echo "$out" + IOS_LAST_PID=$(echo "$out" | awk -F': ' '/^'"$PACKAGE_NAME"': [0-9]+$/ {print $2}' | tail -1) + [[ -n "$IOS_LAST_PID" ]] && log_debug "Launched PID: $IOS_LAST_PID" +} + +ios_get_pid() { + xcrun simctl spawn "$IOS_UDID" launchctl list 2>/dev/null | \ + grep "$PACKAGE_NAME" | awk '{print $1}' | head -1 +} + +ios_collect_logs() { + local log_file="$1" + + ios_ensure_udid + + # Always start from an empty file so each phase capture is self-contained. + : > "$log_file" + + # Strategy 1: Read the app's af_qa_logs.txt from the simulator filesystem. + # This file is the source of truth for [AF_QA] markers because the IOSink + # in af_qa_logger.dart guarantees every line is appended. + local sim_data_dir + sim_data_dir="$HOME/Library/Developer/CoreSimulator/Devices/${IOS_UDID}/data" + if [[ -d "$sim_data_dir" ]]; then + local qa_log + qa_log=$(find "$sim_data_dir/Containers/Data/Application" -name "af_qa_logs.txt" -maxdepth 4 2>/dev/null | head -1) + if [[ -n "$qa_log" && -f "$qa_log" ]]; then + log_debug "Found iOS QA log file: $qa_log" + cat "$qa_log" >> "$log_file" + fi + fi + + # Strategy 2: Always also append simctl log show output. The file logger + # only carries [AF_QA] lines; SDK HTTP traffic (response code:200, etc.) + # only shows up via os_log and is required by count_matches checks. + # Window is 240s so back-to-back phases (cold launch -> 60s settle -> deep + # link -> 12s wait) still fit. The grep filter also captures URL-open + # events from CoreSimulatorBridge / launchservices so deep-link triage has + # something to look at when onDeepLinking doesn't fire. + # + # Pin the predicate to the current Runner PID when known so prior-run + # entries that still sit inside the rolling window can't poison absent- + # pattern checks (e.g. phase_1 no_fatal_errors). Falls back to unfiltered + # when PID is unknown (first phase before launch, or `simctl launch` + # failed to print one). + log_debug "Appending simctl log show output" + local predicate_args=() + if [[ -n "$IOS_LAST_PID" ]]; then + predicate_args=(--predicate "processIdentifier == $IOS_LAST_PID") + fi + xcrun simctl spawn "$IOS_UDID" log show \ + --last 240s --style compact ${predicate_args[@]+"${predicate_args[@]}"} 2>&1 | \ + grep -E "${LOG_TAG}|appsflyer|CFNetwork:Summary|response_status|response code|Opening URL|launchservices|openURL|continueUserActivity" >> "$log_file" || true + + # Best-effort screenshot for failure triage (no-op if nothing booted). + local shot_dir="${log_file%/*}" + local shot_file="${shot_dir}/${log_file##*/}.png" + shot_file="${shot_file%_logs.txt.png}_screen.png" + xcrun simctl io "$IOS_UDID" screenshot "$shot_file" 2>/dev/null || true +} + +ios_background_app() { + ios_ensure_udid + log_info "Backgrounding app (launching Safari)..." + xcrun simctl launch "$IOS_UDID" com.apple.mobilesafari 2>/dev/null || true +} + +ios_trigger_deeplink() { + local url="$1" + ios_ensure_udid + log_info "Triggering deep link: $url" + xcrun simctl openurl "$IOS_UDID" "$url" 2>/dev/null || true +} + +# ─── Platform dispatcher ──────────────────────────────────────────────────── + +platform_uninstall() { + if [[ "$PLATFORM" == "android" ]]; then android_uninstall; else ios_uninstall; fi +} + +platform_install() { + if [[ "$PLATFORM" == "android" ]]; then android_install; else ios_install; fi +} + +platform_launch() { + if [[ "$PLATFORM" == "android" ]]; then android_launch; else ios_launch; fi +} + +platform_collect_logs() { + if [[ "$PLATFORM" == "android" ]]; then android_collect_logs "$1"; else ios_collect_logs "$1"; fi +} + +platform_background() { + if [[ "$PLATFORM" == "android" ]]; then android_background_app; else ios_background_app; fi +} + +platform_trigger_deeplink() { + if [[ "$PLATFORM" == "android" ]]; then android_trigger_deeplink "$1"; else ios_trigger_deeplink "$1"; fi +} + +# Print the device-side af_qa_logs.txt to stdout (best effort, empty on miss). +# Used by `wait_for_qa_marker` to poll mid-phase without reshuffling the full +# log-collection pipeline. +platform_peek_qa_log() { + if [[ "$PLATFORM" == "android" ]]; then + for path in app_flutter/af_qa_logs.txt files/af_qa_logs.txt; do + adb shell "run-as $PACKAGE_NAME cat $path 2>/dev/null" 2>/dev/null && return 0 + done + return 0 + fi + ios_ensure_udid + local sim_data_dir + sim_data_dir="$HOME/Library/Developer/CoreSimulator/Devices/${IOS_UDID}/data" + [[ -d "$sim_data_dir" ]] || return 0 + local qa_log + qa_log=$(find "$sim_data_dir/Containers/Data/Application" \ + -name "af_qa_logs.txt" -maxdepth 4 2>/dev/null | head -1) + [[ -n "$qa_log" && -f "$qa_log" ]] || return 0 + cat "$qa_log" 2>/dev/null || true +} + +# wait_for_qa_marker [interval_sec] +# Polls the device-side QA log and returns 0 as soon as the marker +# appears, or after the timeout (also 0 — caller still runs validation against +# whatever logs exist). Lets local runs finish quickly while CI runs use the +# full ceiling for slow no-KVM emulators / cold macOS sims. +wait_for_qa_marker() { + local marker="$1" + local timeout_sec="$2" + local interval="${3:-3}" + local start_ts now elapsed remaining sleep_for + + start_ts=$(date +%s) + log_info "Waiting up to ${timeout_sec}s for marker: ${marker} (poll every ${interval}s)" + while true; do + now=$(date +%s) + elapsed=$(( now - start_ts )) + if (( elapsed >= timeout_sec )); then + break + fi + + if platform_peek_qa_log | grep -qF -- "$marker" 2>/dev/null; then + log_info "Marker observed after ${elapsed}s" + return 0 + fi + + remaining=$(( timeout_sec - elapsed )) + sleep_for="$interval" + if (( sleep_for > remaining )); then + sleep_for="$remaining" + fi + sleep "$sleep_for" + done + log_warn "Marker not observed within ${timeout_sec}s; proceeding to log collection anyway" + return 0 +} + +LAST_CMD_OUTPUT="" + +run_phase_command() { + local label="$1" + local command="$2" + local allow_failure="$3" + local output status + + log_info "${label}: ${command}" + set +e + output=$(eval "$command" 2>&1) + status=$? + set -e + + LAST_CMD_OUTPUT="$output" + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" >&2 + fi + + if [[ "$status" -ne 0 ]]; then + if [[ "$allow_failure" == "true" ]]; then + log_warn "${label} failed with exit code ${status}; continuing" + return 0 + fi + log_fail "${label} failed with exit code ${status}" + return "$status" + fi +} + +deep_link_wait_marker() { + local phase_json="$1" + echo "$phase_json" | jq -r ' + [ + .checks[]? + | select(.type == "log_contains") + | .pattern + | select(startswith("deepLinkValue=")) + ][0] // empty + ' +} + +# ─── Build ─────────────────────────────────────────────────────────────────── + +build_app() { + if [[ -z "$BUILD_CMD" ]]; then + log_warn "No build_cmd configured in test plan for $PLATFORM" + return 1 + fi + log_step "Building app" + log_info "Running: $BUILD_CMD" + if ! $DRY_RUN; then + (eval "$BUILD_CMD") + fi +} + +# ─── Log validation engine ─────────────────────────────────────────────────── + +# validate_check +# Returns a JSON object: {"status": "PASS|FAIL|WARN", "evidence": "..."} +validate_check() { + local log_file="$1" + local check_json="$2" + + local check_id check_type pattern description fail_action + check_id=$(echo "$check_json" | jq -r '.id') + check_type=$(echo "$check_json" | jq -r '.type') + description=$(echo "$check_json" | jq -r '.description') + fail_action=$(echo "$check_json" | jq -r '.fail_action // "fail"') + + local fail_status="FAIL" + [[ "$fail_action" == "warn" ]] && fail_status="WARN" + + log_debug "Validating check: $check_id ($check_type)" + + case "$check_type" in + + log_contains) + pattern=$(echo "$check_json" | jq -r '.pattern') + local match + match=$(grep -F "$pattern" "$log_file" 2>/dev/null | head -1 || true) + if [[ -n "$match" ]]; then + # Optional payload_check + local payload_field payload_expected + payload_field=$(echo "$check_json" | jq -r '.payload_check.field // empty') + if [[ -n "$payload_field" ]]; then + payload_expected=$(echo "$check_json" | jq -r '.payload_check.expected') + if echo "$match" | grep -q "${payload_field}.*${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "\"${payload_field}\":.*${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "${payload_field}=${payload_expected}" 2>/dev/null || \ + echo "$match" | grep -q "${payload_field}: ${payload_expected}" 2>/dev/null; then + jq -n --arg evidence "$(echo "$match" | head -c 500)" \ + '{status: "PASS", evidence: $evidence}' + else + echo "{\"status\":\"${fail_status}\",\"evidence\":\"Pattern found but payload check failed: ${payload_field} != ${payload_expected}\"}" + fi + else + echo "{\"status\":\"PASS\",\"evidence\":$(echo "$match" | head -c 500 | jq -Rs .)}" + fi + else + echo "{\"status\":\"${fail_status}\",\"evidence\":\"Pattern not found in logs: ${pattern}\"}" + fi + ;; + + count_matches) + pattern=$(echo "$check_json" | jq -r '.pattern') + local minimum + minimum=$(echo "$check_json" | jq -r '.minimum // 1') + local count + count=$(grep -cE "$pattern" "$log_file" 2>/dev/null || echo "0") + if [[ "$count" -ge "$minimum" ]]; then + echo "{\"status\":\"PASS\",\"evidence\":\"Found ${count} matches (minimum: ${minimum})\"}" + else + echo "{\"status\":\"${fail_status}\",\"evidence\":\"Found only ${count} matches (minimum: ${minimum})\"}" + fi + ;; + + absent) + local patterns_json patterns_arr status evidence + patterns_json=$(echo "$check_json" | jq -r '.patterns // []') + status="PASS" + evidence="No forbidden patterns found" + while IFS= read -r forbidden_pattern; do + forbidden_pattern=$(echo "$forbidden_pattern" | jq -r '.') + local found + found=$(grep -F "$forbidden_pattern" "$log_file" 2>/dev/null | head -1 || true) + if [[ -n "$found" ]]; then + status="$fail_status" + evidence="Forbidden pattern found: ${forbidden_pattern} -> $(echo "$found" | head -c 200)" + break + fi + done < <(echo "$patterns_json" | jq -c '.[]') + echo "{\"status\":\"${status}\",\"evidence\":$(echo "$evidence" | jq -Rs .)}" + ;; + + regex_match) + pattern=$(echo "$check_json" | jq -r '.pattern') + local match + match=$(grep -E "$pattern" "$log_file" 2>/dev/null | head -1 || true) + if [[ -n "$match" ]]; then + echo "{\"status\":\"PASS\",\"evidence\":$(echo "$match" | head -c 500 | jq -Rs .)}" + else + echo "{\"status\":\"${fail_status}\",\"evidence\":\"Regex not matched in logs: ${pattern}\"}" + fi + ;; + + *) + echo "{\"status\":\"WARN\",\"evidence\":\"Unknown check type: ${check_type}\"}" + ;; + esac +} + +# ─── Phase execution ───────────────────────────────────────────────────────── + +# run_phase +run_phase() { + local phase_json="$1" + + local phase_id phase_name requires_fresh scenario_ref wait_sec + phase_id=$(echo "$phase_json" | jq -r '.id') + phase_name=$(echo "$phase_json" | jq -r '.name') + requires_fresh=$(echo "$phase_json" | jq -r '.requires_fresh_install // false') + scenario_ref=$(echo "$phase_json" | jq -r '.scenario_ref // "N/A"') + wait_sec=$(echo "$phase_json" | jq -r '.wait_after_launch_sec // 25') + local wait_trigger_sec + wait_trigger_sec=$(echo "$phase_json" | jq -r '.wait_after_trigger_sec // 5') + + log_step "Phase: ${phase_name} [${phase_id}] (Scenario: ${scenario_ref})" + + local phase_log_file="${REPORT_DIR}/${RUN_ID}_${phase_id}_logs.txt" + local phase_status="PASS" + local checks_json="{}" + + if $DRY_RUN; then + log_info "[DRY RUN] Would execute phase: $phase_name" + if [[ "$requires_fresh" == "true" ]]; then + log_info "[DRY RUN] Would uninstall + reinstall $PACKAGE_NAME" + fi + log_info "[DRY RUN] Would launch app and wait ${wait_sec}s" + log_info "[DRY RUN] Would collect logs and validate $(echo "$phase_json" | jq '.checks | length') checks" + + # Produce a dry-run result + local dry_result + dry_result=$(jq -n \ + --arg pid "$phase_id" \ + --arg ps "DRY_RUN" \ + '{phase_id: $pid, status: $ps, checks: {}, log_file: "N/A"}') + PHASE_RESULTS=$(echo "$PHASE_RESULTS" | jq --argjson r "$dry_result" '. + [$r]') + return + fi + + # Fresh install if required + if [[ "$requires_fresh" == "true" ]]; then + platform_uninstall + sleep 1 + if ! platform_install; then + log_fail "Installation failed — aborting phase" + phase_status="BLOCKED" + local blocked_result + blocked_result=$(jq -n \ + --arg pid "$phase_id" \ + --arg ps "$phase_status" \ + '{phase_id: $pid, status: $ps, checks: {}, log_file: "N/A", note: "Installation failed"}') + PHASE_RESULTS=$(echo "$PHASE_RESULTS" | jq --argjson r "$blocked_result" '. + [$r]') + return + fi + sleep 1 + + platform_launch + # Poll the QA log for the auto-run-complete marker rather than always + # sleeping the full ceiling. Use a slower interval here because each ADB + # `run-as cat` is costly on GitHub's emulator. + wait_for_qa_marker "[AF_QA][AUTO_APIS] --- Auto run complete ---" "$wait_sec" 10 + fi + + # Pre-actions (deep link phases: background the app, etc.) + local pre_actions + pre_actions=$(echo "$phase_json" | jq -r ".pre_actions.${PLATFORM} // empty") + if [[ -n "$pre_actions" && "$pre_actions" != "null" ]]; then + log_info "Executing pre-actions..." + while IFS= read -r action; do + action=$(echo "$action" | jq -r '.') + action="${action//\{\{BUNDLE_ID\}\}/$PACKAGE_NAME}" + action="${action//\{\{PACKAGE_NAME\}\}/$PACKAGE_NAME}" + if [[ "$PLATFORM" == "ios" ]]; then + ios_ensure_udid + action="${action//\{\{UDID\}\}/$IOS_UDID}" + fi + run_phase_command "Pre-action" "$action" true + if [[ "$PLATFORM" == "ios" && "$action" == *"simctl launch"* && -n "$LAST_CMD_OUTPUT" ]]; then + local new_pid + new_pid=$(echo "$LAST_CMD_OUTPUT" | awk -F': ' '/^'"$PACKAGE_NAME"': [0-9]+$/ {print $2}' | tail -1) + if [[ -n "$new_pid" ]]; then + IOS_LAST_PID="$new_pid" + log_debug "Pre-action updated PID: $IOS_LAST_PID" + fi + fi + done < <(echo "$phase_json" | jq -c ".pre_actions.${PLATFORM}[]") + fi + + # Trigger deep link if present + local deep_link_url + deep_link_url=$(echo "$phase_json" | jq -r '.deep_link_url // empty') + if [[ -n "$deep_link_url" ]]; then + # Use platform-specific trigger command or generic + local trigger_cmd + trigger_cmd=$(echo "$phase_json" | jq -r ".trigger.${PLATFORM} // empty") + if [[ -n "$trigger_cmd" && "$trigger_cmd" != "null" ]]; then + trigger_cmd="${trigger_cmd//\{\{DEEP_LINK_URL\}\}/$deep_link_url}" + trigger_cmd="${trigger_cmd//\{\{BUNDLE_ID\}\}/$PACKAGE_NAME}" + trigger_cmd="${trigger_cmd//\{\{PACKAGE_NAME\}\}/$PACKAGE_NAME}" + if [[ "$PLATFORM" == "ios" ]]; then + ios_ensure_udid + trigger_cmd="${trigger_cmd//\{\{UDID\}\}/$IOS_UDID}" + fi + if ! run_phase_command "Deep link trigger" "$trigger_cmd" false; then + log_warn "Deep link trigger failed; continuing to collect logs and run checks" + fi + else + if ! platform_trigger_deeplink "$deep_link_url"; then + log_warn "Deep link trigger failed; continuing to collect logs and run checks" + fi + fi + + local deep_link_marker + deep_link_marker=$(deep_link_wait_marker "$phase_json") + if [[ -n "$deep_link_marker" ]]; then + wait_for_qa_marker "$deep_link_marker" "$wait_trigger_sec" 3 + else + log_info "Waiting ${wait_trigger_sec}s for deep link to propagate..." + sleep "$wait_trigger_sec" + fi + fi + + # Collect logs + log_info "Collecting logs..." + platform_collect_logs "$phase_log_file" + + local log_lines + log_lines=$(wc -l < "$phase_log_file" 2>/dev/null | tr -d ' ') + log_info "Collected ${log_lines} log lines" + + if [[ "$log_lines" -eq 0 ]]; then + log_warn "No logs collected — check that the app is running and logging with [AF_QA]" + fi + + # Validate each check + local num_checks + num_checks=$(echo "$phase_json" | jq '.checks | length') + log_info "Running ${num_checks} checks..." + + local i=0 + while [[ $i -lt $num_checks ]]; do + local check + check=$(echo "$phase_json" | jq -c ".checks[$i]") + local check_id + check_id=$(echo "$check" | jq -r '.id') + local check_desc + check_desc=$(echo "$check" | jq -r '.description') + local fail_action + fail_action=$(echo "$check" | jq -r '.fail_action // "fail"') + + local result + result=$(validate_check "$phase_log_file" "$check") + local check_status + check_status=$(echo "$result" | jq -r '.status') + local evidence + evidence=$(echo "$result" | jq -r '.evidence') + + TOTAL_CHECKS=$((TOTAL_CHECKS + 1)) + + if [[ "$check_status" == "PASS" ]]; then + log_ok "$check_id: $check_desc" + PASSED_CHECKS=$((PASSED_CHECKS + 1)) + elif [[ "$check_status" == "WARN" ]]; then + log_warn "$check_id: $check_desc — $evidence" + WARNED_CHECKS=$((WARNED_CHECKS + 1)) + else + log_fail "$check_id: $check_desc — $evidence" + FAILED_CHECKS=$((FAILED_CHECKS + 1)) + phase_status="FAIL" + + if [[ "$fail_action" == "abort" ]]; then + log_fail "Abort triggered by $check_id — skipping remaining checks in this phase" + ABORTED=true + break + fi + fi + + checks_json=$(echo "$checks_json" | jq --arg k "$check_id" --argjson v "$result" '. + {($k): $v}') + i=$((i + 1)) + done + + # Produce phase result + local phase_result + phase_result=$(jq -n \ + --arg pid "$phase_id" \ + --arg ps "$phase_status" \ + --argjson ch "$checks_json" \ + --arg lf "$phase_log_file" \ + '{phase_id: $pid, status: $ps, checks: $ch, log_file: $lf}') + PHASE_RESULTS=$(echo "$PHASE_RESULTS" | jq --argjson r "$phase_result" '. + [$r]') + + echo "" +} + +# ─── Main ──────────────────────────────────────────────────────────────────── + +main() { + log_step "AppsFlyer Smoke Runner" + log_info "Started at $RUN_START" + + # Verify device/simulator is available (skip in dry-run) + if $DRY_RUN; then + if [[ "$PLATFORM" == "android" ]]; then + local device + device=$(android_get_device 2>/dev/null || true) + log_info "Android device: ${device:-}" + else + IOS_UDID=$(ios_get_booted_udid 2>/dev/null || true) + log_info "iOS simulator: ${IOS_UDID:-}" + fi + else + if [[ "$PLATFORM" == "android" ]]; then + local device + device=$(android_get_device) + if [[ -z "$device" ]]; then + log_fail "No Android device/emulator found. Start one with: emulator -avd " + exit 1 + fi + log_info "Android device: $device" + elif [[ "$PLATFORM" == "ios" ]]; then + ios_ensure_udid + fi + fi + + # Build if requested + if $BUILD_FIRST; then + build_app + fi + + # Get phases from the plan + local num_phases + num_phases=$(echo "$PLAN" | jq '.phases | length') + log_info "Test plan has ${num_phases} phases" + + local p=0 + while [[ $p -lt $num_phases ]]; do + local phase + phase=$(echo "$PLAN" | jq -c ".phases[$p]") + local pid + pid=$(echo "$phase" | jq -r '.id') + + # Apply phase filter if set + if [[ -n "$PHASE_FILTER" && "$pid" != "$PHASE_FILTER" ]]; then + log_debug "Skipping phase $pid (filter: $PHASE_FILTER)" + p=$((p + 1)) + continue + fi + + run_phase "$phase" + + if $ABORTED; then + log_warn "Run aborted after phase $pid" + break + fi + + p=$((p + 1)) + done + + # ── Final report ────────────────────────────────────────────────────────── + + local overall_status="PASS" + if [[ $FAILED_CHECKS -gt 0 ]]; then + overall_status="FAIL" + fi + if $ABORTED; then + overall_status="ABORTED" + fi + + local run_end + run_end=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local start_epoch end_epoch duration_sec + start_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$RUN_START" +%s 2>/dev/null || date -d "$RUN_START" +%s 2>/dev/null || echo "0") + end_epoch=$(date +%s) + duration_sec=$(( end_epoch - start_epoch )) + + local device_name="" + if [[ "$PLATFORM" == "android" ]]; then + device_name=$(android_get_device 2>/dev/null || echo "N/A") + else + device_name="${IOS_UDID:-N/A}" + fi + + local report + report=$(jq -n \ + --arg rid "$RUN_ID" \ + --arg plat "$PLATFORM" \ + --arg dev "$device_name" \ + --arg plan "$PLAN_ID" \ + --arg plugin "$PLUGIN_NAME" \ + --arg status "$overall_status" \ + --arg start "$RUN_START" \ + --arg end "$run_end" \ + --argjson dur "$duration_sec" \ + --argjson total "$TOTAL_CHECKS" \ + --argjson passed "$PASSED_CHECKS" \ + --argjson failed "$FAILED_CHECKS" \ + --argjson warned "$WARNED_CHECKS" \ + --argjson phases "$PHASE_RESULTS" \ + '{ + run_id: $rid, + platform: $plat, + device: $dev, + plan_id: $plan, + plugin: $plugin, + overall_status: $status, + started_at: $start, + finished_at: $end, + duration_sec: $dur, + total_checks: $total, + passed: $passed, + failed: $failed, + warned: $warned, + phases: $phases + }') + + # Write report + if ! $DRY_RUN; then + echo "$report" | jq '.' > "$REPORT_FILE" + # Also write a latest.json symlink + ln -sf "$(basename "$REPORT_FILE")" "${REPORT_DIR}/latest.json" + log_info "Report saved to: $REPORT_FILE" + fi + + # Print summary + echo "" + log_step "Summary" + echo -e " Plan: ${PLAN_ID}" + echo -e " Plugin: ${PLUGIN_NAME}" + echo -e " Platform: ${PLATFORM}" + echo -e " Device: ${device_name}" + echo -e " Checks: ${PASSED_CHECKS}/${TOTAL_CHECKS} passed, ${FAILED_CHECKS} failed, ${WARNED_CHECKS} warned" + + if [[ "$overall_status" == "PASS" ]]; then + echo -e " Status: ${GREEN}${BOLD}PASS${NC}" + elif [[ "$overall_status" == "ABORTED" ]]; then + echo -e " Status: ${RED}${BOLD}ABORTED${NC}" + else + echo -e " Status: ${RED}${BOLD}FAIL${NC}" + fi + echo "" + + # Exit with appropriate code + if [[ "$overall_status" != "PASS" ]]; then + exit 1 + fi +} + +main From 28fc7aa1fa1b439d7c79319837f2b802e98b1de2 Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 12 May 2026 15:11:28 +0300 Subject: [PATCH 05/21] Add QA test app for E2E validation with auto-run flow --- example/.eslintrc.js | 4 + example/.gitignore | 78 +++ example/.prettierrc.js | 5 + example/App.tsx | 1 + example/Gemfile | 17 + example/README.md | 97 ++++ example/__tests__/App.test.tsx | 13 + example/_bundle/config | 2 + example/android/app/build.gradle | 121 +++++ example/android/app/debug.keystore | Bin 0 -> 2257 bytes example/android/app/proguard-rules.pro | 10 + .../android/app/src/main/AndroidManifest.xml | 35 ++ .../appsflyer/qa/reactnative/MainActivity.kt | 22 + .../qa/reactnative/MainApplication.kt | 27 + .../res/drawable/rn_edit_text_material.xml | 37 ++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3056 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5024 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2096 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2858 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4569 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7098 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6464 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10676 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9250 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15523 bytes .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/styles.xml | 9 + example/android/build.gradle | 21 + example/android/gradle.properties | 44 ++ .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + example/android/settings.gradle | 6 + example/app.json | 4 + example/babel.config.js | 3 + example/index.js | 9 + example/ios/.xcode.env | 1 + example/ios/Podfile | 34 ++ example/ios/_xcode.env | 11 + example/ios/example.xcodeproj/project.pbxproj | 498 ++++++++++++++++++ .../xcshareddata/xcschemes/example.xcscheme | 88 ++++ .../contents.xcworkspacedata | 10 + example/ios/example/AfQaNativeLogger.m | 15 + example/ios/example/AppDelegate.swift | 67 +++ .../AppIcon.appiconset/Contents.json | 53 ++ .../ios/example/Images.xcassets/Contents.json | 6 + example/ios/example/Info.plist | 69 +++ example/ios/example/LaunchScreen.storyboard | 47 ++ example/ios/example/PrivacyInfo.xcprivacy | 37 ++ example/jest.config.js | 3 + example/metro.config.js | 34 ++ example/package.json | 44 ++ example/src/AfQaLogger.ts | 25 + example/src/App.tsx | 151 ++++++ example/src/react-native-config.d.ts | 9 + example/tsconfig.json | 10 + 55 files changed, 1787 insertions(+) create mode 100644 example/.eslintrc.js create mode 100644 example/.gitignore create mode 100644 example/.prettierrc.js create mode 100644 example/App.tsx create mode 100644 example/Gemfile create mode 100644 example/README.md create mode 100644 example/__tests__/App.test.tsx create mode 100644 example/_bundle/config create mode 100644 example/android/app/build.gradle create mode 100644 example/android/app/debug.keystore create mode 100644 example/android/app/proguard-rules.pro create mode 100644 example/android/app/src/main/AndroidManifest.xml create mode 100644 example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt create mode 100644 example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt create mode 100644 example/android/app/src/main/res/drawable/rn_edit_text_material.xml create mode 100644 example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 example/android/app/src/main/res/values/strings.xml create mode 100644 example/android/app/src/main/res/values/styles.xml create mode 100644 example/android/build.gradle create mode 100644 example/android/gradle.properties create mode 100644 example/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 example/android/settings.gradle create mode 100644 example/app.json create mode 100644 example/babel.config.js create mode 100644 example/index.js create mode 100644 example/ios/.xcode.env create mode 100644 example/ios/Podfile create mode 100644 example/ios/_xcode.env create mode 100644 example/ios/example.xcodeproj/project.pbxproj create mode 100644 example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme create mode 100644 example/ios/example.xcworkspace/contents.xcworkspacedata create mode 100644 example/ios/example/AfQaNativeLogger.m create mode 100644 example/ios/example/AppDelegate.swift create mode 100644 example/ios/example/Images.xcassets/AppIcon.appiconset/Contents.json create mode 100644 example/ios/example/Images.xcassets/Contents.json create mode 100644 example/ios/example/Info.plist create mode 100644 example/ios/example/LaunchScreen.storyboard create mode 100644 example/ios/example/PrivacyInfo.xcprivacy create mode 100644 example/jest.config.js create mode 100644 example/metro.config.js create mode 100644 example/package.json create mode 100644 example/src/AfQaLogger.ts create mode 100644 example/src/App.tsx create mode 100644 example/src/react-native-config.d.ts create mode 100644 example/tsconfig.json diff --git a/example/.eslintrc.js b/example/.eslintrc.js new file mode 100644 index 00000000..187894b6 --- /dev/null +++ b/example/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: '@react-native', +}; diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 00000000..d0ab6e83 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,78 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Environment variables +.env + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/example/.prettierrc.js b/example/.prettierrc.js new file mode 100644 index 00000000..06860c8d --- /dev/null +++ b/example/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + arrowParens: 'avoid', + singleQuote: true, + trailingComma: 'all', +}; diff --git a/example/App.tsx b/example/App.tsx new file mode 100644 index 00000000..bc2e7ccc --- /dev/null +++ b/example/App.tsx @@ -0,0 +1 @@ +export {default} from './src/App'; diff --git a/example/Gemfile b/example/Gemfile new file mode 100644 index 00000000..51515233 --- /dev/null +++ b/example/Gemfile @@ -0,0 +1,17 @@ +source 'https://rubygems.org' + +# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version +ruby ">= 2.6.10" + +# Exclude problematic versions of cocoapods and activesupport that causes build failures. +gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' +gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' +gem 'xcodeproj', '< 1.26.0' +gem 'concurrent-ruby', '< 1.3.4' + +# Ruby 3.4.0 has removed some libraries from the standard library. +gem 'bigdecimal' +gem 'logger' +gem 'benchmark' +gem 'mutex_m' +gem 'nkf' diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..3e2c3f85 --- /dev/null +++ b/example/README.md @@ -0,0 +1,97 @@ +This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). + +# Getting Started + +> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. + +## Step 1: Start Metro + +First, you will need to run **Metro**, the JavaScript build tool for React Native. + +To start the Metro dev server, run the following command from the root of your React Native project: + +```sh +# Using npm +npm start + +# OR using Yarn +yarn start +``` + +## Step 2: Build and run your app + +With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: + +### Android + +```sh +# Using npm +npm run android + +# OR using Yarn +yarn android +``` + +### iOS + +For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). + +The first time you create a new project, run the Ruby bundler to install CocoaPods itself: + +```sh +bundle install +``` + +Then, and every time you update your native dependencies, run: + +```sh +bundle exec pod install +``` + +For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). + +```sh +# Using npm +npm run ios + +# OR using Yarn +yarn ios +``` + +If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. + +This is one way to run your app — you can also build it directly from Android Studio or Xcode. + +## Step 3: Modify your app + +Now that you have successfully run the app, let's make changes! + +Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). + +When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: + +- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). +- **iOS**: Press R in iOS Simulator. + +## Congratulations! :tada: + +You've successfully run and modified your React Native App. :partying_face: + +### Now what? + +- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). +- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). + +# Troubleshooting + +If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. + +# Learn More + +To learn more about React Native, take a look at the following resources: + +- [React Native Website](https://reactnative.dev) - learn more about React Native. +- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. +- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. +- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. +- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. diff --git a/example/__tests__/App.test.tsx b/example/__tests__/App.test.tsx new file mode 100644 index 00000000..e532f701 --- /dev/null +++ b/example/__tests__/App.test.tsx @@ -0,0 +1,13 @@ +/** + * @format + */ + +import React from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import App from '../App'; + +test('renders correctly', async () => { + await ReactTestRenderer.act(() => { + ReactTestRenderer.create(); + }); +}); diff --git a/example/_bundle/config b/example/_bundle/config new file mode 100644 index 00000000..848943bb --- /dev/null +++ b/example/_bundle/config @@ -0,0 +1,2 @@ +BUNDLE_PATH: "vendor/bundle" +BUNDLE_FORCE_RUBY_PLATFORM: 1 diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 00000000..4fe51977 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,121 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" +apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js + // cliFile = file("../../node_modules/react-native/cli.js") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + // + // The command to run when bundling. By default is 'bundle' + // bundleCommand = "ram-bundle" + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace "com.appsflyer.qa.reactnative" + defaultConfig { + applicationId "com.appsflyer.engagement" + resValue "string", "build_config_package", "com.appsflyer.qa.reactnative" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/example/android/app/debug.keystore b/example/android/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..364e105ed39fbfd62001429a68140672b06ec0de GIT binary patch literal 2257 zcmchYXEfYt8;7T1^dLH$VOTZ%2NOdOH5j5LYLtZ0q7x-V8_6gU5)#7dkq{HTmsfNq zB3ZqcAxeY^G10@?efK?Q&)M(qInVv!xjx+IKEL}p*K@LYvIzo#AZG>st5|P)KF1_Z;y){W{<7K{nl!CPuE z_^(!C(Ol0n8 zK13*rzAtW>(wULKPRYLd7G18F8#1P`V*9`(Poj26eOXYyBVZPno~Cvvhx7vPjAuZo zF?VD!zB~QG(!zbw#qsxT8%BSpqMZ4f70ZPn-3y$L8{EVbbN9$H`B&Z1quk9tgp5FM zuxp3pJ0b8u|3+#5bkJ4SRnCF2l7#DyLYXYY8*?OuAwK4E6J{0N=O3QNVzQ$L#FKkR zi-c@&!nDvezOV$i$Lr}iF$XEcwnybQ6WZrMKuw8gCL^U#D;q3t&HpTbqyD%vG=TeDlzCT~MXUPC|Leb-Uk+ z=vnMd(|>ld?Fh>V8poP;q;;nc@en$|rnP0ytzD&fFkCeUE^kG9Kx4wUh!!rpjwKDP zyw_e|a^x_w3E zP}}@$g>*LLJ4i0`Gx)qltL}@;mDv}D*xR^oeWcWdPkW@Uu)B^X&4W1$p6}ze!zudJ zyiLg@uggoMIArBr*27EZV7djDg@W1MaL+rcZ-lrANJQ%%>u8)ZMWU@R2qtnmG(acP z0d_^!t>}5W zpT`*2NR+0+SpTHb+6Js4b;%LJB;B_-ChhnU5py}iJtku*hm5F0!iql8Hrpcy1aYbT z1*dKC5ua6pMX@@iONI?Hpr%h;&YaXp9n!ND7-=a%BD7v&g zOO41M6EbE24mJ#S$Ui0-brR5ML%@|ndz^)YLMMV1atna{Fw<;TF@>d&F|!Z>8eg>>hkFrV)W+uv=`^F9^e zzzM2*oOjT9%gLoub%(R57p-`TXFe#oh1_{&N-YN z<}artH|m=d8TQuKSWE)Z%puU|g|^^NFwC#N=@dPhasyYjoy(fdEVfKR@cXKHZV-`06HsP`|Ftx;8(YD$fFXumLWbGnu$GMqRncXYY9mwz9$ap zQtfZB^_BeNYITh^hA7+(XNFox5WMeG_LtJ%*Q}$8VKDI_p8^pqX)}NMb`0e|wgF7D zuQACY_Ua<1ri{;Jwt@_1sW9zzdgnyh_O#8y+C;LcZq6=4e^cs6KvmK@$vVpKFGbQ= z$)Eux5C|Fx;Gtmv9^#Y-g@7Rt7*eLp5n!gJmn7&B_L$G?NCN`AP>cXQEz}%F%K;vUs{+l4Q{}eWW;ATe2 zqvXzxoIDy(u;F2q1JH7Sf;{jy_j})F+cKlIOmNfjBGHoG^CN zM|Ho&&X|L-36f}Q-obEACz`sI%2f&k>z5c$2TyTSj~vmO)BW~+N^kt`Jt@R|s!){H ze1_eCrlNaPkJQhL$WG&iRvF*YG=gXd1IyYQ9ew|iYn7r~g!wOnw;@n42>enAxBv*A zEmV*N#sxdicyNM=A4|yaOC5MByts}s_Hpfj|y<6G=o=!3S@eIFKDdpR7|FY>L&Wat&oW&cm&X~ z5Bt>Fcq(fgnvlvLSYg&o6>&fY`ODg4`V^lWWD=%oJ#Kbad2u~! zLECFS*??>|vDsNR&pH=Ze0Eo`sC_G`OjoEKVHY|wmwlX&(XBE<@sx3Hd^gtd-fNwUHsylg06p`U2y_={u}Bc + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt new file mode 100644 index 00000000..bffeea84 --- /dev/null +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt @@ -0,0 +1,22 @@ +package com.appsflyer.qa.reactnative + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "example" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) +} diff --git a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt new file mode 100644 index 00000000..2e5982ee --- /dev/null +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt @@ -0,0 +1,27 @@ +package com.appsflyer.qa.reactnative + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + }, + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} diff --git a/example/android/app/src/main/res/drawable/rn_edit_text_material.xml b/example/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 00000000..5c25e728 --- /dev/null +++ b/example/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a2f5908281d070150700378b64a84c7db1f97aa1 GIT binary patch literal 3056 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 axPAxe%7rDusX+w*00006jj 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⁣ zwiG+x#%j}{0gU~J8;<|!B1@-XaB@{KORFwrYg_8rOv({b0EO#DbeQRm;B6_9=mXGf z-x|VL{zd`)#@yN}HkCSJbjbNlE|zL3Wm9Q8HY`sV)}3%pgN>cL^67{Z;PPL(*wT8N zUjXU{@|*hvm}({wsAC=x0^ok0%UAz0;sogW{B!nDqk|JJ5x~4NfTDgP49^zeu`csl?5mY@JdQdISc zFs!E{^grmkLnUk9 zny~m)1vws@5BFI<-0Tuo2JWX(0v`W|t(wg;s--L47WTvTMz-8l#TL^=OJNRS2?_Qj z3AKT+gvbyBi#H*-tJ%tWD|>EV3wy|8qxfzS!5RW;Jpl5*zo&^UBU=fG#2}UvRyNkK zA06Dy9;K1ca@r2T>yThYgI!ont$(G{6q#2QT+00r_x0(b)gsE`lBB?2gr55gq^D3Fi&p%E(p9>U%bv zkg1Jco(RbyTX7FDHOnl7-O@ zI$AaIl?9NJKPm(WiBP`1-#CB1QzU>&hKm)fpa5DKE{2$X0hGz-0uZ?cyTk(YC!Y&| zL=1VrNERSA5NA2jq7FACfX4JfPyj5XXl1yv0>~s;eF7L2$>&oMqeTFT2m$y7FlkON z_yurD1yIOvA;5C6016pyxBznGUt0kJ&k5r#;&>Jow`r)sp9R~PmK~lz$3xH%LT*1U zJdOyABZ3!FvNoR*vN$5ykHS8f`jA4zV+|L}i1C4`B2c{R0;UdYxaU|H)2avz@ z=mEYc|2S<+(B2Tj+FkX+2D+yFI!k9lWMA61DJ{)e;lum$(;O87?vGJJe!KtK04+N_ zI*P~t@dUb>9Xh{dbyl{-ZQ(UMgz7$|QfL5XSPkskt^NgctYC#;4WcZB1@%@wy@2t3 z2z0DI7&%b$*Aw~abe?GxE`ez@+6hOh-6*8fHRV{1os$EL@}uUZeG4h1&Be`98q*7j z=3-v+lhIjfWVo12!<>%V^a6lTgW3+_#W6n|p*~==zOH7z$0{LSZk(Tpd7EaD04hnA zL;#fxS0aD{`5^&D`}>0Uq?byDD-l2=!wm_bLcUl4gc(% za1p|itVANvFF>hghAS07Im1;IK;|b*W)}VDyI;BIp2=K*yu2a)j?B|f<44NI$NbmJ z#dE0>jI$fMr&@>4kN8MLFb4&2O9fEKaQg%(QO$4_1rVQywG^CmBLh#}_7gKW3vd?| z2?1^&KWq8}8I^_S0|)MowU_pw$q@nl@Nkn$z>BQq_KA^9yaR`(R3u{{Ig;cwt z@AJ^{ODQCm^neroM9nKNUAXi9RCK`OsP_LuR0PUR(YZCCX5dNF6VzcoK&=b^r`W?ltt|*F zpkoae%ZT{C1h~EcFui~b7fF`vb<<~j_VquuUA$}QqIKYELPp#;{u?q8Dz}WAG-(3; zjrm$i%7UbyZMM(Y{>!uJ#vNB?R~B{6Htp=>e*<{fQQ5W7V(1coCWlOON!MzZxhum| ztZBQpGR z;~#ur^&PockKdV{Q6R>o`Pl{0x!DEbpZ7y9Y;*ZvE!*gU`V1W3znva{f=?WO5I&>B z&hw6}tjECtaghm5z|C#%M;Yf_*pI^};h}Vl=^r9EN=tVDj86D;C$jIJ?K7VP+00000NkvXXu0mjf D5i!M* literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..459ca609d3ae0d3943ab44cdc27feef9256dc6d7 GIT binary patch literal 7098 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 literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8ca12fe024be86e868d14e91120a6902f8e88ac6 GIT binary patch literal 6464 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 B0*L?s literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..8e19b410a1b15ff180f3dacac19395fe3046cdec GIT binary patch literal 10676 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 literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4c19a13c239cb67b8a2134ddd5f325db1d2d5bee GIT binary patch literal 15523 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 BIP(Ai literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/values/strings.xml b/example/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..d75426c8 --- /dev/null +++ b/example/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + example + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..7ba83a2a --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 00000000..dad99b02 --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,21 @@ +buildscript { + ext { + buildToolsVersion = "36.0.0" + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + ndkVersion = "27.1.12297006" + kotlinVersion = "2.1.20" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } +} + +apply plugin: "com.facebook.react.rootproject" diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 00000000..9afe6159 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,44 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.jar b/example/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 0 HcmV?d00001 diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..37f78a6a --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 00000000..dfa7590c --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +rootProject.name = 'example' +include ':app' +includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/example/app.json b/example/app.json new file mode 100644 index 00000000..3fed6761 --- /dev/null +++ b/example/app.json @@ -0,0 +1,4 @@ +{ + "name": "example", + "displayName": "example" +} diff --git a/example/babel.config.js b/example/babel.config.js new file mode 100644 index 00000000..f7b3da3b --- /dev/null +++ b/example/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/example/index.js b/example/index.js new file mode 100644 index 00000000..9b739329 --- /dev/null +++ b/example/index.js @@ -0,0 +1,9 @@ +/** + * @format + */ + +import { AppRegistry } from 'react-native'; +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/example/ios/.xcode.env b/example/ios/.xcode.env new file mode 100644 index 00000000..772b339b --- /dev/null +++ b/example/ios/.xcode.env @@ -0,0 +1 @@ +export NODE_BINARY=$(command -v node) diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 00000000..8963f93d --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,34 @@ +# Resolve react_native_pods.rb with node to allow for hoisting +require Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/scripts/react_native_pods.rb", + {paths: [process.argv[1]]}, + )', __dir__]).strip + +platform :ios, min_ios_version_supported +prepare_react_native_project! + +linkage = ENV['USE_FRAMEWORKS'] +if linkage != nil + Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green + use_frameworks! :linkage => linkage.to_sym +end + +target 'example' do + config = use_native_modules! + + use_react_native!( + :path => config[:reactNativePath], + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/.." + ) + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + # :ccache_enabled => true + ) + end +end diff --git a/example/ios/_xcode.env b/example/ios/_xcode.env new file mode 100644 index 00000000..3d5782c7 --- /dev/null +++ b/example/ios/_xcode.env @@ -0,0 +1,11 @@ +# This `.xcode.env` file is versioned and is used to source the environment +# used when running script phases inside Xcode. +# To customize your local environment, you can create an `.xcode.env.local` +# file that is not versioned. + +# NODE_BINARY variable contains the PATH to the node executable. +# +# Customize the NODE_BINARY variable here. +# For example, to use nvm with brew, add the following line +# . "$(brew --prefix nvm)/nvm.sh" --no-use +export NODE_BINARY=$(command -v node) diff --git a/example/ios/example.xcodeproj/project.pbxproj b/example/ios/example.xcodeproj/project.pbxproj new file mode 100644 index 00000000..4b5f1b70 --- /dev/null +++ b/example/ios/example.xcodeproj/project.pbxproj @@ -0,0 +1,498 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0C80B921A6F3F58F76C31292 /* libPods-example.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + 9734C8DD146FBB8196A2F6FB /* AfQaNativeLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = F3B03121EAB4CDFC23402C6E /* AfQaNativeLogger.m */; }; + D9EE6CD494DDF34D28F2DD14 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = example/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = example/Info.plist; sourceTree = ""; }; + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = example/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 3B4392A12AC88292D35C810B /* Pods-example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.debug.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.debug.xcconfig"; sourceTree = ""; }; + 5709B34CF0A7D63546082F79 /* Pods-example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.release.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.release.xcconfig"; sourceTree = ""; }; + 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-example.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = example/AppDelegate.swift; sourceTree = ""; }; + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = example/LaunchScreen.storyboard; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + F3B03121EAB4CDFC23402C6E /* AfQaNativeLogger.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = AfQaNativeLogger.m; path = example/AfQaNativeLogger.m; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C80B921A6F3F58F76C31292 /* libPods-example.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* example */ = { + isa = PBXGroup; + children = ( + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 761780EC2CA45674006654EE /* AppDelegate.swift */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + F3B03121EAB4CDFC23402C6E /* AfQaNativeLogger.m */, + ); + name = example; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* example */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + BBD78D7AC51CEA395F1C20DB /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* example.app */, + ); + name = Products; + sourceTree = ""; + }; + BBD78D7AC51CEA395F1C20DB /* Pods */ = { + isa = PBXGroup; + children = ( + 3B4392A12AC88292D35C810B /* Pods-example.debug.xcconfig */, + 5709B34CF0A7D63546082F79 /* Pods-example.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* example */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "example" */; + buildPhases = ( + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = example; + productName = example; + productReference = 13B07F961A680F5B00A75B9A /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1210; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1120; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "example" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* example */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + D9EE6CD494DDF34D28F2DD14 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/.xcode.env", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n"; + }; + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-example-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + 9734C8DD146FBB8196A2F6FB /* AfQaNativeLogger.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-example.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = example/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.appsflyer.qa.reactnative; + PRODUCT_NAME = example; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-example.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = example/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.appsflyer.qa.reactnative; + PRODUCT_NAME = example; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme b/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme new file mode 100644 index 00000000..fef9df78 --- /dev/null +++ b/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/example.xcworkspace/contents.xcworkspacedata b/example/ios/example.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..7f5c3aab --- /dev/null +++ b/example/ios/example.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/ios/example/AfQaNativeLogger.m b/example/ios/example/AfQaNativeLogger.m new file mode 100644 index 00000000..61044372 --- /dev/null +++ b/example/ios/example/AfQaNativeLogger.m @@ -0,0 +1,15 @@ +#import +#import + +@interface AfQaNativeLogger : NSObject +@end + +@implementation AfQaNativeLogger + +RCT_EXPORT_MODULE(); + +RCT_EXPORT_METHOD(log:(NSString *)message) { + NSLog(@"%@", message); +} + +@end diff --git a/example/ios/example/AppDelegate.swift b/example/ios/example/AppDelegate.swift new file mode 100644 index 00000000..c6a95e71 --- /dev/null +++ b/example/ios/example/AppDelegate.swift @@ -0,0 +1,67 @@ +import UIKit +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import react_native_appsflyer + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + var reactNativeDelegate: ReactNativeDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = RCTReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + window = UIWindow(frame: UIScreen.main.bounds) + + factory.startReactNative( + withModuleName: "example", + in: window, + launchOptions: launchOptions + ) + + return true + } + + func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + AppsFlyerAttribution.shared().handleOpen(url, options: options) + return true + } + + func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([Any]?) -> Void + ) -> Bool { + AppsFlyerAttribution.shared().continue(userActivity, restorationHandler: restorationHandler) + return true + } +} + +class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { + override func sourceURL(for bridge: RCTBridge) -> URL? { + self.bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") +#else + Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/example/ios/example/Images.xcassets/AppIcon.appiconset/Contents.json b/example/ios/example/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..81213230 --- /dev/null +++ b/example/ios/example/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,53 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/example/ios/example/Images.xcassets/Contents.json b/example/ios/example/Images.xcassets/Contents.json new file mode 100644 index 00000000..2d92bd53 --- /dev/null +++ b/example/ios/example/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/example/Info.plist b/example/ios/example/Info.plist new file mode 100644 index 00000000..b4cb3c17 --- /dev/null +++ b/example/ios/example/Info.plist @@ -0,0 +1,69 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + afqa-reactnative + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSLocationWhenInUseUsageDescription + + RCTNewArchEnabled + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/example/ios/example/LaunchScreen.storyboard b/example/ios/example/LaunchScreen.storyboard new file mode 100644 index 00000000..a2139fff --- /dev/null +++ b/example/ios/example/LaunchScreen.storyboard @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/example/PrivacyInfo.xcprivacy b/example/ios/example/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..bad32761 --- /dev/null +++ b/example/ios/example/PrivacyInfo.xcprivacy @@ -0,0 +1,37 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/example/jest.config.js b/example/jest.config.js new file mode 100644 index 00000000..294be30f --- /dev/null +++ b/example/jest.config.js @@ -0,0 +1,3 @@ +module.exports = { + preset: '@react-native/jest-preset', +}; diff --git a/example/metro.config.js b/example/metro.config.js new file mode 100644 index 00000000..600d7295 --- /dev/null +++ b/example/metro.config.js @@ -0,0 +1,34 @@ +const path = require('path'); +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); + +const pluginRoot = path.resolve(__dirname, '..'); + +/** + * Metro configuration + * https://reactnative.dev/docs/metro + * + * @type {import('@react-native/metro-config').MetroConfig} + */ +const config = { + watchFolders: [pluginRoot], + resolver: { + nodeModulesPaths: [ + path.resolve(__dirname, 'node_modules'), + path.resolve(pluginRoot, 'node_modules'), + ], + extraNodeModules: { + 'react-native': path.resolve(__dirname, 'node_modules/react-native'), + react: path.resolve(__dirname, 'node_modules/react'), + }, + blockList: [ + new RegExp( + path.resolve(pluginRoot, 'node_modules/react-native').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*', + ), + new RegExp( + path.resolve(pluginRoot, 'node_modules/react').replace(/[/\\]/g, '[/\\\\]') + '[/\\\\].*', + ), + ], + }, +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/example/package.json b/example/package.json new file mode 100644 index 00000000..4b882656 --- /dev/null +++ b/example/package.json @@ -0,0 +1,44 @@ +{ + "name": "example", + "version": "0.0.1", + "private": true, + "scripts": { + "android": "react-native run-android", + "ios": "react-native run-ios", + "lint": "eslint .", + "start": "react-native start", + "test": "jest" + }, + "dependencies": { + "@react-native/new-app-screen": "0.85.3", + "react": "19.2.3", + "react-native": "0.85.3", + "react-native-appsflyer": "file:..", + "react-native-config": "^1.6.1", + "react-native-safe-area-context": "^5.5.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "20.1.0", + "@react-native-community/cli-platform-android": "20.1.0", + "@react-native-community/cli-platform-ios": "20.1.0", + "@react-native/babel-preset": "0.85.3", + "@react-native/eslint-config": "0.85.3", + "@react-native/jest-preset": "0.85.3", + "@react-native/metro-config": "0.85.3", + "@react-native/typescript-config": "0.85.3", + "@types/jest": "^29.5.13", + "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.1.0", + "eslint": "^8.19.0", + "jest": "^29.6.3", + "prettier": "2.8.8", + "react-test-renderer": "19.2.3", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">= 22.11.0" + } +} diff --git a/example/src/AfQaLogger.ts b/example/src/AfQaLogger.ts new file mode 100644 index 00000000..551176d9 --- /dev/null +++ b/example/src/AfQaLogger.ts @@ -0,0 +1,25 @@ +import {NativeModules, Platform} from 'react-native'; + +const LOG_TAG = '[AF_QA]'; + +// On iOS, console.log goes only to Metro (not os_log). Use a native module +// so logs appear in `simctl log show` for the scenario runner to capture. +const nativeLog = + Platform.OS === 'ios' && NativeModules.AfQaNativeLogger + ? (msg: string) => { + NativeModules.AfQaNativeLogger.log(msg); + console.log(msg); + } + : (msg: string) => console.log(msg); + +export function afLog(method: string, message: string): void { + nativeLog(`${LOG_TAG}[${method}] ${message}`); +} + +export function afCallbackLog(callbackName: string, payload: string): void { + nativeLog(`${LOG_TAG}[CALLBACK][${callbackName}] received: ${payload}`); +} + +export function afLifecycleLog(message: string): void { + nativeLog(`${LOG_TAG}[AUTO_APIS] ${message}`); +} diff --git a/example/src/App.tsx b/example/src/App.tsx new file mode 100644 index 00000000..5797d72f --- /dev/null +++ b/example/src/App.tsx @@ -0,0 +1,151 @@ +// @ts-nocheck — QA test app; runtime correctness verified against index.d.ts signatures +import React, {useEffect} from 'react'; +import {Platform, View, Text, StyleSheet} from 'react-native'; +import appsFlyer, {AppsFlyerConsent} from 'react-native-appsflyer'; +import {afLog, afCallbackLog, afLifecycleLog} from './AfQaLogger'; +import Config from 'react-native-config'; + +export default function App() { + useEffect(() => { + runAutoFlow(); + }, []); + + return ( + + AF QA Test App - React Native + + ); +} + +function runAutoFlow() { + const devKey = Config.DEV_KEY; + const appId = Config.APP_ID; + + if (!devKey) { + afLog('CONFIG', 'DEV_KEY missing'); + return; + } + + // 1. Register callbacks BEFORE initSdk (critical — listeners must be attached first) + appsFlyer.onInstallConversionData(data => { + afCallbackLog('onInstallConversionData', JSON.stringify(data)); + }); + appsFlyer.onAppOpenAttribution(data => { + afCallbackLog('onAppOpenAttribution', JSON.stringify(data)); + }); + appsFlyer.onDeepLink(data => { + afCallbackLog( + 'onDeepLinking', + `status=${data.deepLinkStatus}, deepLinkValue=${data.data?.deep_link_value || 'N/A'}`, + ); + }); + + // 2. Pre-start APIs + appsFlyer.setCustomerUserId('qa-test-user', result => { + afLog('setCustomerUserId', `result: ${result}`); + }); + + appsFlyer.setCurrencyCode('USD', result => { + afLog('setCurrencyCode', `result: ${result}`); + }); + + afLifecycleLog('--- Pre-start auto APIs complete ---'); + + // 3. Start SDK with manualStart — initSdk configures, startSdk fires the actual start + appsFlyer.initSdk( + { + devKey: devKey, + isDebug: true, + appId: Platform.OS === 'ios' ? appId : undefined, + manualStart: true, + onInstallConversionDataListener: true, + onDeepLinkListener: true, + }, + result => { + afLog('initSdk', `result: ${JSON.stringify(result)}`); + }, + error => { + afLog('initSdk', `error: ${JSON.stringify(error)}`); + }, + ); + + // startSdk() is void (fire-and-forget); success confirmed via onInstallConversionData callback + appsFlyer.startSdk(); + afLog('startSDK', 'result: called'); + + // 4. Post-start APIs + appsFlyer.getAppsFlyerUID((err, uid) => { + afLog('getAppsFlyerUID', `result: ${uid || err}`); + }); + + appsFlyer.getSDKVersion((err, version) => { + afLog('getSDKVersion', `result: ${version || err}`); + }); + + afLifecycleLog('--- Post-start auto APIs complete ---'); + + // 5. Fire standard events + appsFlyer.logEvent( + 'af_demo_launch', + {platform: 'react-native'}, + result => { + afLog('logEvent(af_demo_launch)', `result: ${result}`); + }, + error => { + afLog('logEvent(af_demo_launch)', `error: ${error}`); + }, + ); + + appsFlyer.logEvent( + 'af_purchase', + { + af_revenue: '12.99', + af_currency: 'USD', + af_content_id: 'qa-item-001', + }, + result => { + afLog('logEvent(af_purchase)', `result: ${result}`); + }, + error => { + afLog('logEvent(af_purchase)', `error: ${error}`); + }, + ); + + appsFlyer.logEvent( + 'af_content_view', + { + af_content_id: 'qa-content-001', + af_content_type: 'test', + }, + result => { + afLog('logEvent(af_content_view)', `result: ${result}`); + }, + error => { + afLog('logEvent(af_content_view)', `error: ${error}`); + }, + ); + + // 6. Consent & sharing APIs + appsFlyer.setSharingFilterForPartners(['partner_test']); + afLog('setSharingFilterForPartners', 'result: [partner_test]'); + + const consent = new AppsFlyerConsent(true, true, true, true); + appsFlyer.setConsentData(consent); + afLog('setConsentData', 'result: GDPR consent set'); + + afLifecycleLog('--- Auto run complete ---'); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F5F5F5', + }, + title: { + fontSize: 18, + fontWeight: '600', + color: '#333', + }, +}); diff --git a/example/src/react-native-config.d.ts b/example/src/react-native-config.d.ts new file mode 100644 index 00000000..b6bed68d --- /dev/null +++ b/example/src/react-native-config.d.ts @@ -0,0 +1,9 @@ +declare module 'react-native-config' { + interface NativeConfig { + DEV_KEY?: string; + APP_ID?: string; + [key: string]: string | undefined; + } + const Config: NativeConfig; + export default Config; +} diff --git a/example/tsconfig.json b/example/tsconfig.json new file mode 100644 index 00000000..aa6440c1 --- /dev/null +++ b/example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@react-native/typescript-config", + "compilerOptions": { + "types": ["jest"], + "strict": false, + "noImplicitAny": false + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["**/node_modules", "**/Pods"] +} From b4d49ed7f6020ec3ac9ba65715d6c8bc4c3c676d Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 12 May 2026 15:11:40 +0300 Subject: [PATCH 06/21] Add getSDKVersion bridge method and startSdk API --- .../java/com/appsflyer/reactnative/RNAppsFlyerModule.java | 7 +++++++ index.d.ts | 1 + index.js | 4 ++++ ios/RNAppsFlyer.m | 5 +++++ 4 files changed, 17 insertions(+) diff --git a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java index a095ccfb..5b0df5ae 100755 --- a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java +++ b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java @@ -474,6 +474,13 @@ public void getAppsFlyerUID(Callback callback) { guardedCallback.invoke(null, appId); } + @ReactMethod + public void getSDKVersion(Callback callback) { + CallbackGuard guardedCallback = new CallbackGuard(callback); + String version = AppsFlyerLib.getInstance().getSdkVersion(); + guardedCallback.invoke(null, version); + } + @ReactMethod public void updateServerUninstallToken(final String token, Callback callback) { CallbackGuard guardedCallback = new CallbackGuard(callback); diff --git a/index.d.ts b/index.d.ts index 3850d005..412ce598 100644 --- a/index.d.ts +++ b/index.d.ts @@ -311,6 +311,7 @@ declare module "react-native-appsflyer" { ): void; setAdditionalData(additionalData: object, successC?: SuccessCB): void; getAppsFlyerUID(callback: (error: Error, uid: string) => any): void; + getSDKVersion(callback: (error: Error, version: string) => any): void; setCustomerUserId(userId: string, successC?: SuccessCB): void; stop(isStopped: boolean, successC?: SuccessCB): void; setAppInviteOneLinkID(oneLinkID: string, successC?: SuccessCB): void; diff --git a/index.js b/index.js index 03017e7d..ba179c06 100755 --- a/index.js +++ b/index.js @@ -415,6 +415,10 @@ appsFlyer.getAppsFlyerUID = (callback) => { return RNAppsFlyer.getAppsFlyerUID(callback); }; +appsFlyer.getSDKVersion = (callback) => { + return RNAppsFlyer.getSDKVersion(callback); +}; + /** * Manually pass the Firebase / GCM Device Token for Uninstall measurement. * diff --git a/ios/RNAppsFlyer.m b/ios/RNAppsFlyer.m index 987d7e9f..93ed7bf2 100755 --- a/ios/RNAppsFlyer.m +++ b/ios/RNAppsFlyer.m @@ -236,6 +236,11 @@ -(NSError *) callSdkInternal:(NSDictionary*)initSdkOptions { callback(@[[NSNull null], uid]); } +RCT_EXPORT_METHOD(getSDKVersion: (RCTResponseSenderBlock)callback) { + NSString *version = [[AppsFlyerLib shared] getSDKVersion]; + callback(@[[NSNull null], version]); +} + RCT_EXPORT_METHOD(setCustomerUserId: (NSString *)userId callback:(RCTResponseSenderBlock)callback) { [[AppsFlyerLib shared] setCustomerUserID:userId]; callback(@[AF_SUCCESS]); From d4505f7a0471d5befc3fd42d6575c47fad8ea93d Mon Sep 17 00:00:00 2001 From: Amit Levy Date: Tue, 12 May 2026 15:11:48 +0300 Subject: [PATCH 07/21] Add RC release operator manual --- RELEASE_USER_MANUAL.md | 170 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 RELEASE_USER_MANUAL.md diff --git a/RELEASE_USER_MANUAL.md b/RELEASE_USER_MANUAL.md new file mode 100644 index 00000000..0db94b4a --- /dev/null +++ b/RELEASE_USER_MANUAL.md @@ -0,0 +1,170 @@ +# Release operator manual + +Step-by-step guide for cutting, validating, and shipping a React Native plugin release using the automated RC pipeline. + +## Prerequisites + +- **GitHub**: write access to `AppsFlyerSDK/appsflyer-react-native-plugin`, ability to run Actions +- **npm**: publish rights for the `react-native-appsflyer` package +- **Jira**: access to the project for fix-version validation +- **Slack**: member of the release notification channel + +## Workflow overview + +``` +rc-release.yml ─── lint-test-build.yml ───┐ + ios-e2e.yml ───────────┤ + android-e2e.yml ───────┤ + ▼ + publish RC to npm + create pre-release + PR + │ + rc-smoke.yml (auto-triggers) + │ + QA adds "pass QA ready for deploy" label + │ + promote-release.yml + (strips -rcN suffix) + │ + Human merges PR to master + │ + production-release.yml + (publishes to npm @latest) +``` + +## 1. Cut a release candidate + +1. Go to **Actions** > **RC - Release Candidate** > **Run workflow**. +2. Fill in the required inputs: + +| Input | Required | Example | Description | +|-------|----------|---------|-------------| +| `rn_version` | Yes | `6.18.0-rc1` | Plugin version. Must match `X.Y.Z-rcN` format. | +| `ios_sdk_version` | Yes | `6.18.0` | iOS native AppsFlyer SDK version to pin. | +| `android_sdk_version` | Yes | `6.18.0` | Android native AppsFlyer SDK version to pin. | +| `base_branch` | No | `development` | Branch to cut the release from (default: `development`). | +| `pc_version` | No | `6.15.2` | PurchaseConnector iOS version override. Leave empty to auto-fetch latest from GitHub. | +| `skip_unit` | No | `false` | Skip Jest + ESLint inside Lint, Test & Build. | +| `skip_builds` | No | `false` | Skip Android + iOS release builds inside Lint, Test & Build. | +| `skip_e2e` | No | `false` | Skip iOS + Android E2E test jobs. Blocks publish if not skipped. | +| `dry_run` | No | `true` | **Default is true.** Set to `false` to actually publish to npm. Dry runs still create the branch and PR. | + +3. Click **Run workflow**. + +## 2. What the pipeline does automatically + +Once triggered, the pipeline runs these stages in order: + +1. **Validate inputs** -- checks version format, branch existence, and Jira fix version. +2. **Lint, Test & Build** -- Jest tests, ESLint, Android release build, iOS release build. +3. **Create release branch** -- creates `releases/X.Y.Z-rcN` from `base_branch` with version bumps in: + - `package.json` (version field; `react-native-appsflyer.podspec` reads from this) + - `android/build.gradle` (Android SDK fallback version) + - `android/.../RNAppsFlyerConstants.java` (PLUGIN_VERSION) + - `ios/RNAppsFlyer.h` (kAppsFlyerPluginVersion) + - `README.md` (SDK version badges) + - `CHANGELOG.md` (new entry prepended) +4. **E2E tests** -- iOS and Android E2E suites run on the release branch. +5. **Publish RC** -- `npm publish --tag rc` (skipped if `dry_run` is true). +6. **GitHub pre-release** -- created with release notes. +7. **PR to master** -- auto-opened from the release branch. +8. **Slack notification** -- posts to the release channel. +9. **Smoke tests** -- `rc-smoke.yml` triggers automatically after the RC workflow succeeds. Installs the RC from npm in a fresh project and runs smoke scenarios. Posts a `rc-smoke/npm` check-run on the release branch. + +## 3. QA validation + +1. Wait for the `rc-smoke/npm` check-run to appear green on the PR. +2. Install the RC in a test app and validate manually: + ```bash + npm install react-native-appsflyer@6.18.0-rc1 + ``` +3. Test attribution, deep linking, and any feature changes specific to this release. +4. If the RC passes QA, apply the label **`pass QA ready for deploy`** to the PR. + +If the RC fails QA: fix the issue on `development`, then cut a new RC with an incremented suffix (e.g., `6.18.0-rc2`). + +## 4. Promote to production + +When the `pass QA ready for deploy` label is applied: + +1. `promote-release.yml` triggers automatically. +2. It verifies `rc-smoke/npm` passed, then strips the `-rcN` suffix from all version files on the release branch and commits. +3. **You must manually merge the PR to master.** The bot cannot merge (org policy). + +## 5. Production publish + +When the PR merges to master: + +1. `production-release.yml` triggers automatically. +2. It publishes to npm with the `latest` tag. +3. Creates a GitHub release (not pre-release). +4. Notifies Slack. + +## 6. Post-release verification + +```bash +# Verify npm +npm view react-native-appsflyer version +# Expected: 6.18.0 + +# Verify GitHub +# Check https://github.com/AppsFlyerSDK/appsflyer-react-native-plugin/releases +``` + +## Troubleshooting + +### RC smoke tests failed + +Check the `rc-smoke.yml` run logs and the `.af-e2e/reports` artifacts. Common causes: +- npm registry propagation delay (retry the smoke workflow manually) +- E2E infrastructure flake (re-run the workflow via Actions > RC Smoke > Run workflow) + +### Promote blocked: "rc-smoke/npm is missing" + +The smoke workflow hasn't finished or wasn't triggered. Run `rc-smoke.yml` manually with the RC version and release branch, wait for it to pass, then re-apply the `pass QA ready for deploy` label. + +### npm publish succeeded but PR/Slack failed + +The published package is valid. Manually complete the failed steps: +- Create the PR from the release branch to master if missing. +- Post to Slack manually. + +### npm publish failed + +Fix the root cause (auth, network, version conflict), then re-run the RC workflow with the **same version**. npm rejects duplicate version+tag combos, so if the version was partially published, you may need to `npm unpublish react-native-appsflyer@6.18.0-rc1` first (within 72h) or bump to `-rc2`. + +### Wrong content published to npm + +1. Deprecate the bad version: + ```bash + npm deprecate react-native-appsflyer@6.18.0-rc1 "broken, use 6.18.0-rc2" + ``` +2. Fix the issue on `development`. +3. Cut a new RC with the next suffix (`-rc2`). + +### E2E tests failed during RC + +Check `.af-e2e/reports` artifacts in the workflow run. E2E failures block npm publish (unless `skip_e2e` was set). Fix the issue, then either: +- Re-run the failed E2E job from the Actions UI, or +- Cut a new RC if code changes are needed. + +### production-release.yml did not trigger after merge + +The workflow triggers on `pull_request: closed` to `master` from `releases/*` branches. If it didn't fire: +- Verify the PR was merged (not just closed). +- Verify the source branch matched `releases/*`. +- Use the manual dispatch: Actions > Production Release > Run workflow, enter the version. + +## Version files reference + +These files contain version strings. The RC and promote workflows update them automatically. Listed here for manual intervention scenarios. + +| File | Field | Updated by | +|------|-------|------------| +| `package.json` | `"version"` | RC workflow | +| `react-native-appsflyer.podspec` | `s.version` (reads from package.json) | Indirect | +| `android/build.gradle` | `appsflyerVersion` fallback | RC workflow | +| `android/.../RNAppsFlyerConstants.java` | `PLUGIN_VERSION` | RC workflow | +| `ios/RNAppsFlyer.h` | `kAppsFlyerPluginVersion` | RC workflow | +| `README.md` | SDK version badges | RC workflow | +| `CHANGELOG.md` | Release entry | RC workflow | From 5dac0473dda9840d0bebc63d331c1b51c18e57f4 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Tue, 12 May 2026 16:33:13 +0300 Subject: [PATCH 08/21] fix: CI workflow correctness fixes for RC pipeline --- .af-e2e/test-plan.json | 4 +- .claude/rules/known-issues-kb.md | 7 ++ .github/workflows/android-e2e.yml | 4 +- .github/workflows/ios-e2e.yml | 4 +- .github/workflows/production-release.yml | 10 +- .github/workflows/promote-release.yml | 2 +- .github/workflows/rc-release.yml | 21 ++-- .github/workflows/rc-smoke.yml | 6 +- Makefile | 111 ++++++++++++++++++ .../appsflyer/qa/reactnative/MainActivity.kt | 14 +-- example/src/App.tsx | 51 +++----- scripts/af-scenario-runner.sh | 8 +- 12 files changed, 173 insertions(+), 69 deletions(-) create mode 100644 Makefile diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index c6c1816f..e8b42f59 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -11,8 +11,8 @@ "config": { "android": { - "package_name": "com.appsflyer.qa.reactnative", - "activity": ".MainActivity", + "package_name": "com.appsflyer.engagement", + "activity": "com.appsflyer.qa.reactnative.MainActivity", "apk_path": "example/android/app/build/outputs/apk/debug/app-debug.apk", "build_cmd": "cd example/android && ./gradlew assembleDebug" }, diff --git a/.claude/rules/known-issues-kb.md b/.claude/rules/known-issues-kb.md index e71f0d48..787ed0b0 100644 --- a/.claude/rules/known-issues-kb.md +++ b/.claude/rules/known-issues-kb.md @@ -87,6 +87,13 @@ Issue-based KB derived from real GitHub issues. Reference when debugging user re **Root cause:** `logEvent` called before `initSdk` completes. **Fix:** Await `initSdk` resolution before calling `logEvent`. +### logEvent callback never fires on Android (CallbackGuard WeakReference) +**Issues:** discovered in E2E testing (2026-05-12) +**Root cause:** `CallbackGuard` (added in 6.17.8) wraps `Callback` in `WeakReference`. All other methods invoke callbacks synchronously before the `@ReactMethod` returns, so the strong reference on the call stack keeps them alive. `logEvent` is the only method where the callback fires asynchronously — `AppsFlyerRequestListener.onSuccess()` runs on a background thread ~2s later after the HTTP round-trip. By then, GC has collected the weakly-referenced `Callback`. +**Symptoms:** Native SDK sends events successfully (200 OK in logcat), but JS success/error callbacks are silently swallowed. No error logged. +**Fix:** Use the Promise-based API (`logEvent(name, values)` without callbacks → returns Promise) which uses `Promise` instead of `Callback`. `Promise` is held strongly by the bridge and is not affected. +**Long-term fix:** `CallbackGuard` should use a strong reference for async callbacks, or `logEvent` should keep a strong reference alongside the `WeakReference`. + ## Privacy / ATT / compliance (20 issues) ### ITMS-91064 App Store rejection diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index b9358cb8..7919c0be 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -84,7 +84,7 @@ jobs: - name: Write example app .env env: ENV_FILE: ${{ secrets.ENV_FILE }} - run: printf '%s\n' "$ENV_FILE" > example/.env + run: echo "$ENV_FILE" | base64 -d > example/.env - name: Build and run E2E on Android emulator uses: reactivecircus/android-emulator-runner@v2 @@ -101,7 +101,7 @@ jobs: set -e adb shell 'getprop net.dns1; getprop net.dns2' || true adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } - cd example/android && ./gradlew assembleDebug && cd ../../.. + cd example/android && ./gradlew assembleDebug && cd ../.. ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json - name: Upload E2E reports diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 4a466209..bf6b81d2 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -104,7 +104,7 @@ jobs: uses: actions/cache@v5 with: path: example/ios/Pods - key: pods-${{ hashFiles('example/ios/Podfile.lock') }} + key: pods-${{ hashFiles('example/ios/Podfile', 'react-native-appsflyer.podspec') }} restore-keys: pods- - name: Install example app dependencies @@ -118,7 +118,7 @@ jobs: - name: Write example app .env env: ENV_FILE: ${{ secrets.ENV_FILE }} - run: printf '%s\n' "$ENV_FILE" > example/.env + run: echo "$ENV_FILE" | base64 -d > example/.env - name: Cache iOS build output uses: actions/cache@v5 diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml index 90c7d108..68c829a4 100644 --- a/.github/workflows/production-release.yml +++ b/.github/workflows/production-release.yml @@ -155,7 +155,7 @@ jobs: name: Publish to npm runs-on: ubuntu-latest needs: [validate-release] - if: always() && needs.validate-release.outputs.is_valid == 'true' + if: needs.validate-release.result == 'success' && needs.validate-release.outputs.is_valid == 'true' steps: - name: Checkout repository @@ -212,7 +212,7 @@ jobs: name: Create GitHub Release runs-on: ubuntu-latest needs: [validate-release, publish-to-npm] - if: always() && needs.validate-release.outputs.is_valid == 'true' && needs.validate-release.outputs.is_dry_run != 'true' + if: needs.publish-to-npm.result == 'success' && needs.validate-release.outputs.is_dry_run != 'true' steps: - name: Checkout repository @@ -323,7 +323,7 @@ jobs: VERSION: ${{ needs.validate-release.outputs.version }} run: | # Extract Android SDK fallback version from build.gradle - ANDROID_SDK_VERSION=$(grep -oP "af-android-sdk:\K[^')]*" android/build.gradle | head -1) + ANDROID_SDK_VERSION=$(grep "af-android-sdk" android/build.gradle | grep -oP "'[0-9][^']*'" | tr -d "'" | head -1) echo "android_sdk=$ANDROID_SDK_VERSION" >> "$GITHUB_OUTPUT" # Extract iOS SDK version from podspec @@ -433,7 +433,7 @@ jobs: "text": "\n:react::react::react::react::react::react::react::react::react::react::react::react:\n\n*React Native:*\nnpm install react-native-appsflyer@${{ needs.validate-release.outputs.version }} is published to Production.\n\n:white_check_mark: rc-smoke/npm passed before promotion (verified by promote-release.yml).\n\n*Sources:*\n:github: https://github.com/${{ github.repository }}/tree/master\n:github: Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n:npm: https://www.npmjs.com/package/react-native-appsflyer/v/${{ needs.validate-release.outputs.version }}\n\n*Changes and fixes:*\n${{ steps.extract-info.outputs.changelog }}\n\n*Linked tickets and issues:*\n${{ steps.jira-tickets.outputs.tickets }}\n\n*Native SDKs:*\n:android: ${{ steps.extract-info.outputs.android_sdk }}\n:apple: ${{ steps.extract-info.outputs.ios_sdk }}\n\n:react::react::react::react::react::react::react::react::react::react::react::react:" } env: - SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} - name: Send Slack failure notification if: steps.status.outputs.success == 'false' @@ -444,7 +444,7 @@ jobs: "text": "\n:warning: *React Native production release failed at `${{ steps.status.outputs.failed_stage }}`*\n\nVersion: ${{ needs.validate-release.outputs.version }}\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Stage results:*\n- validate-release: ${{ needs.validate-release.result }}\n- publish-to-npm: ${{ needs.publish-to-npm.result }}\n- create-github-release: ${{ needs.create-github-release.result }}" } env: - SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} # =========================================================================== # Job 5: Production Release Summary diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 649ebbe8..222cbc1d 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -238,5 +238,5 @@ jobs: "text": "\n:warning: *React Native promote-release blocked*\n\nPR: ${{ github.event.pull_request.html_url }}\nBranch: ${{ github.event.pull_request.head.ref }}\n\nThe promote workflow could not prepare the release branch for production. Common causes:\n- `rc-smoke/npm` is missing or red on the PR head SHA --- bump to `rcN+1` and rerun rc-release.\n- The version-strip push was rejected (branch protection or stale ref).\n\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" } env: - SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} continue-on-error: true diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index c867b313..02d223ed 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -238,13 +238,16 @@ jobs: # PurchaseConnector (conditional dep, has its own version) if grep -q "PurchaseConnector" react-native-appsflyer.podspec; then - PC_VER=$(curl -s "https://api.github.com/repos/AppsFlyerSDK/appsflyer-apple-purchase-connector/releases/latest" | jq -r '.tag_name') - if [[ -n "$PC_VER" && "$PC_VER" != "null" ]]; then - echo "Auto-fetched PurchaseConnector version: $PC_VER" - elif [[ -n "${PC_VER_INPUT:-}" ]]; then + if [[ -n "${PC_VER_INPUT:-}" ]]; then PC_VER="$PC_VER_INPUT" - echo "Using manual PurchaseConnector version: $PC_VER" + echo "Using manual PurchaseConnector version override: $PC_VER" else + PC_VER=$(curl -s "https://api.github.com/repos/AppsFlyerSDK/appsflyer-apple-purchase-connector/releases/latest" | jq -r '.tag_name') + if [[ -n "$PC_VER" && "$PC_VER" != "null" ]]; then + echo "Auto-fetched PurchaseConnector version: $PC_VER" + fi + fi + if [[ -z "$PC_VER" || "$PC_VER" == "null" ]]; then echo "::error::Could not fetch latest PurchaseConnector version and no pc_version input provided." exit 1 fi @@ -374,10 +377,10 @@ jobs: if [[ "$CI_RESULT" == "success" || "$CI_RESULT" == "skipped" ]]; then ci_ok=true fi - if [[ "$E2E_IOS_RESULT" == "success" ]]; then + if [[ "$E2E_IOS_RESULT" == "success" || "$E2E_IOS_RESULT" == "skipped" ]]; then ios_ok=true fi - if [[ "$E2E_ANDROID_RESULT" == "success" ]]; then + if [[ "$E2E_ANDROID_RESULT" == "success" || "$E2E_ANDROID_RESULT" == "skipped" ]]; then android_ok=true fi @@ -788,7 +791,7 @@ jobs: "text": "\n:react::react::react::react::react::react::react::react::react::react::react::react:\n\n*React Native Release Candidate:*\nreact-native-appsflyer: ${{ needs.validate-release.outputs.version }} is ready for QA testing.\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n\n*Testing Instructions:*\n```\nnpm install react-native-appsflyer@${{ needs.validate-release.outputs.version }} --save\n```\n\n*Sources:*\n:github: https://github.com/${{ github.repository }}/tree/${{ github.ref_name }}\n:github: Release: https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate-release.outputs.version }}\n:github: Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Changes and fixes:*\n${{ steps.extract-info.outputs.changelog }}\n\n*Linked tickets and issues:*\n${{ steps.jira-tickets.outputs.tickets }}\n\n*Native SDKs:*\n:android: ${{ steps.extract-info.outputs.android_sdk }}\n:apple: ${{ steps.extract-info.outputs.ios_sdk }}\n\n:react::react::react::react::react::react::react::react::react::react::react::react:" } env: - SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} - name: Send failure notification if: steps.status.outputs.success == 'false' && needs.validate-release.outputs.is_dry_run != 'true' @@ -799,7 +802,7 @@ jobs: "text": "\n:warning: *React Native RC failed at `${{ steps.status.outputs.failed_stage }}`*\n\nVersion: ${{ needs.validate-release.outputs.version }}\nBranch: ${{ github.ref_name }}\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n\n*Downstream stages:*\n- prepare-branch: ${{ needs.prepare-branch.result }}\n- pre-publish-gate: ${{ needs.pre-publish-gate.result }}\n- validate-jira: ${{ needs.validate-jira.result }}\n- publish-rc: ${{ needs.publish-rc.result }}\n- create-prerelease: ${{ needs.create-prerelease.result }}" } env: - SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} # =========================================================================== # RC Summary diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml index 52196234..cfb8169a 100644 --- a/.github/workflows/rc-smoke.yml +++ b/.github/workflows/rc-smoke.yml @@ -206,7 +206,7 @@ jobs: - name: Write .env env: ENV_FILE: ${{ secrets.ENV_FILE }} - run: printf '%s\n' "$ENV_FILE" > example_rc_smoke/.env + run: echo "$ENV_FILE" | base64 -d > example_rc_smoke/.env - name: Select Xcode version run: | @@ -333,7 +333,7 @@ jobs: - name: Write .env env: ENV_FILE: ${{ secrets.ENV_FILE }} - run: printf '%s\n' "$ENV_FILE" > example_rc_smoke/.env + run: echo "$ENV_FILE" | base64 -d > example_rc_smoke/.env - name: Install npm RC working-directory: example_rc_smoke @@ -440,7 +440,7 @@ jobs: "text": "\n:warning: *React Native rc-smoke failed for `${{ needs.resolve.outputs.rc_version }}`*\n\nThe RC is published on npm but smoke checks did not pass on the artifact. Production promotion is blocked.\n\n*Smoke results:*\n- iOS: ${{ needs.smoke-ios.result }}\n- Android: ${{ needs.smoke-android.result }}\n\n*Branch:* ${{ needs.resolve.outputs.release_branch }}\n*Run:* https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Next step:* Bump to `rcN+1` and rerun the RC-release workflow with `dry_run=false`. Republishing the same RC version is not supported by npm." } env: - SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} # =========================================================================== # Post a skipped check-run when the parent run was dry, the version is not diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..a1064940 --- /dev/null +++ b/Makefile @@ -0,0 +1,111 @@ +# ─── AppsFlyer React Native Plugin — E2E Runner ───────────────────────────── +# +# Usage: +# make e2e-ios Run all phases on iOS simulator +# make e2e-android Run all phases on Android emulator +# make e2e-ios PHASE=phase_2 Run single phase on iOS +# make e2e-android PHASE=phase_3 Run single phase on Android +# make e2e-all Run full E2E on both platforms +# make build-ios Build iOS example app +# make build-android Build Android example app +# make e2e-ios-build Build + run iOS E2E +# make e2e-android-build Build + run Android E2E +# make report Show latest report +# make clean Remove build artifacts and reports + +SHELL := /bin/bash +RUNNER := scripts/af-scenario-runner.sh +PLAN := .af-e2e/test-plan.json + +# Override these or set as env vars +IOS_SIMULATOR_UDID ?= $(shell xcrun simctl list devices booted 2>/dev/null | grep -oE '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}' | head -1) +ANDROID_SERIAL ?= $(shell adb devices 2>/dev/null | awk 'NR==2 && /device$$/{print $$1}') + +# Optional: run a single phase +PHASE ?= +VERBOSE ?= + +# Build flags +_PHASE_FLAG := $(if $(PHASE),--phase $(PHASE),) +_VERBOSE_FLAG := $(if $(VERBOSE),--verbose,) + +# ─── iOS ──────────────────────────────────────────────────────────────────── + +.PHONY: build-ios +build-ios: + @echo "Building iOS example app..." + cd example/ios && xcodebuild build \ + -workspace example.xcworkspace \ + -scheme example \ + -configuration Debug \ + -destination 'platform=iOS Simulator,id=$(IOS_SIMULATOR_UDID)' \ + -derivedDataPath build \ + | tail -3 + +.PHONY: e2e-ios +e2e-ios: + @test -n "$(IOS_SIMULATOR_UDID)" || { echo "Error: no booted iOS simulator found. Boot one first."; exit 1; } + IOS_SIMULATOR_UDID=$(IOS_SIMULATOR_UDID) /bin/bash $(RUNNER) \ + --platform ios --plan $(PLAN) $(_PHASE_FLAG) $(_VERBOSE_FLAG) + +.PHONY: e2e-ios-build +e2e-ios-build: build-ios e2e-ios + +# ─── Android ──────────────────────────────────────────────────────────────── + +.PHONY: build-android +build-android: + @echo "Building Android example app..." + cd example/android && ./gradlew assembleDebug -q + +.PHONY: e2e-android +e2e-android: + @test -n "$(ANDROID_SERIAL)" || { echo "Error: no Android device/emulator found. Start one first."; exit 1; } + ANDROID_SERIAL=$(ANDROID_SERIAL) /bin/bash $(RUNNER) \ + --platform android --plan $(PLAN) $(_PHASE_FLAG) $(_VERBOSE_FLAG) + +.PHONY: e2e-android-build +e2e-android-build: build-android e2e-android + +# ─── Both ─────────────────────────────────────────────────────────────────── + +.PHONY: e2e-all +e2e-all: e2e-ios e2e-android + +# ─── Utilities ────────────────────────────────────────────────────────────── + +.PHONY: report +report: + @latest=$$(ls -t .af-e2e/reports/*.json 2>/dev/null | head -1); \ + if [ -z "$$latest" ]; then echo "No reports found."; exit 1; fi; \ + echo "Latest report: $$latest"; \ + python3 -m json.tool "$$latest" | head -40 + +.PHONY: reports +reports: + @ls -lt .af-e2e/reports/*.json 2>/dev/null | head -10 || echo "No reports found." + +.PHONY: clean +clean: + rm -rf .af-e2e/reports/* .af-smoke/reports/* + rm -rf example/ios/build + cd example/android && ./gradlew clean -q 2>/dev/null || true + @echo "Cleaned build artifacts and reports." + +.PHONY: help +help: + @echo "AppsFlyer React Native E2E Runner" + @echo "" + @echo " make e2e-ios Run all E2E phases on iOS" + @echo " make e2e-android Run all E2E phases on Android" + @echo " make e2e-ios PHASE=phase_1 Run single phase on iOS" + @echo " make e2e-android PHASE=phase_2 Run single phase on Android" + @echo " make e2e-ios VERBOSE=1 Run with verbose output" + @echo " make e2e-all Run on both platforms" + @echo " make e2e-ios-build Build then run iOS" + @echo " make e2e-android-build Build then run Android" + @echo " make build-ios Build iOS only" + @echo " make build-android Build Android only" + @echo " make report Show latest report" + @echo " make reports List recent reports" + @echo " make clean Remove artifacts and reports" diff --git a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt index bffeea84..2c5e47f2 100644 --- a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt @@ -1,5 +1,6 @@ package com.appsflyer.qa.reactnative +import android.content.Intent import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled @@ -7,16 +8,13 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate class MainActivity : ReactActivity() { - /** - * Returns the name of the main component registered from JavaScript. This is used to schedule - * rendering of the component. - */ override fun getMainComponentName(): String = "example" - /** - * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] - * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] - */ override fun createReactActivityDelegate(): ReactActivityDelegate = DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } } diff --git a/example/src/App.tsx b/example/src/App.tsx index 5797d72f..2d8ef0c1 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -84,46 +84,29 @@ function runAutoFlow() { afLifecycleLog('--- Post-start auto APIs complete ---'); - // 5. Fire standard events - appsFlyer.logEvent( - 'af_demo_launch', - {platform: 'react-native'}, - result => { - afLog('logEvent(af_demo_launch)', `result: ${result}`); - }, - error => { - afLog('logEvent(af_demo_launch)', `error: ${error}`); - }, - ); - - appsFlyer.logEvent( - 'af_purchase', - { + // 5. Fire standard events (Promise API — Android CallbackGuard WeakReference + // GC's async Callback objects before AppsFlyerRequestListener fires) + appsFlyer + .logEvent('af_demo_launch', {platform: 'react-native'}) + .then((result: any) => afLog('logEvent(af_demo_launch)', `result: ${result}`)) + .catch((error: any) => afLog('logEvent(af_demo_launch)', `error: ${error}`)); + + appsFlyer + .logEvent('af_purchase', { af_revenue: '12.99', af_currency: 'USD', af_content_id: 'qa-item-001', - }, - result => { - afLog('logEvent(af_purchase)', `result: ${result}`); - }, - error => { - afLog('logEvent(af_purchase)', `error: ${error}`); - }, - ); + }) + .then((result: any) => afLog('logEvent(af_purchase)', `result: ${result}`)) + .catch((error: any) => afLog('logEvent(af_purchase)', `error: ${error}`)); - appsFlyer.logEvent( - 'af_content_view', - { + appsFlyer + .logEvent('af_content_view', { af_content_id: 'qa-content-001', af_content_type: 'test', - }, - result => { - afLog('logEvent(af_content_view)', `result: ${result}`); - }, - error => { - afLog('logEvent(af_content_view)', `error: ${error}`); - }, - ); + }) + .then((result: any) => afLog('logEvent(af_content_view)', `result: ${result}`)) + .catch((error: any) => afLog('logEvent(af_content_view)', `error: ${error}`)); // 6. Consent & sharing APIs appsFlyer.setSharingFilterForPartners(['partner_test']); diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index 7b84cb5c..fb3235b4 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -583,13 +583,13 @@ validate_check() { echo "$match" | grep -q "\"${payload_field}\":.*${payload_expected}" 2>/dev/null || \ echo "$match" | grep -q "${payload_field}=${payload_expected}" 2>/dev/null || \ echo "$match" | grep -q "${payload_field}: ${payload_expected}" 2>/dev/null; then - jq -n --arg evidence "$(echo "$match" | head -c 500)" \ + jq -n --arg evidence "$(echo "$match" | tr -d '\000-\037' | head -c 500)" \ '{status: "PASS", evidence: $evidence}' else echo "{\"status\":\"${fail_status}\",\"evidence\":\"Pattern found but payload check failed: ${payload_field} != ${payload_expected}\"}" fi else - echo "{\"status\":\"PASS\",\"evidence\":$(echo "$match" | head -c 500 | jq -Rs .)}" + echo "{\"status\":\"PASS\",\"evidence\":$(echo "$match" | tr -d '\000-\037' | head -c 500 | jq -Rs .)}" fi else echo "{\"status\":\"${fail_status}\",\"evidence\":\"Pattern not found in logs: ${pattern}\"}" @@ -601,7 +601,9 @@ validate_check() { local minimum minimum=$(echo "$check_json" | jq -r '.minimum // 1') local count - count=$(grep -cE "$pattern" "$log_file" 2>/dev/null || echo "0") + count=$(grep -cE "$pattern" "$log_file" 2>/dev/null | tail -1 || echo "0") + count="${count//[^0-9]/}" + [[ -z "$count" ]] && count=0 if [[ "$count" -ge "$minimum" ]]; then echo "{\"status\":\"PASS\",\"evidence\":\"Found ${count} matches (minimum: ${minimum})\"}" else From 296eec9bdab1f5dc7a32dcb95f28fde20a2691b5 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Tue, 12 May 2026 16:41:05 +0300 Subject: [PATCH 09/21] ci: Remove npm cache from GitHub Actions workflows --- .github/workflows/android-e2e.yml | 1 - .github/workflows/ios-e2e.yml | 1 - .github/workflows/lint-test-build.yml | 3 --- .github/workflows/rc-release.yml | 2 -- 4 files changed, 7 deletions(-) diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 7919c0be..f27b632f 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -75,7 +75,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - name: Install example app dependencies working-directory: example diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index bf6b81d2..fb9779f7 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -55,7 +55,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - name: Select Xcode version run: | diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 4d2e5b84..dfac9fc9 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -56,7 +56,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - run: npm ci - run: npm run lint - run: npm test -- --coverage @@ -71,7 +70,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - uses: actions/setup-java@v5 with: distribution: 'temurin' @@ -94,7 +92,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - name: Install example app dependencies working-directory: example run: npm ci diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index 02d223ed..f30db76b 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -194,7 +194,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - name: Create release branch id: branch @@ -461,7 +460,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - name: Install dependencies run: npm ci From 9722b2a990dacbdf3631e7127b624b5ce1d32a1a Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Tue, 12 May 2026 16:44:23 +0300 Subject: [PATCH 10/21] ci: Update dependency and Gradle build steps in workflows --- .github/workflows/android-e2e.yml | 4 ++-- .github/workflows/ios-e2e.yml | 2 +- .github/workflows/lint-test-build.yml | 9 ++++++--- .github/workflows/rc-release.yml | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index f27b632f..5574c1ab 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -78,7 +78,7 @@ jobs: - name: Install example app dependencies working-directory: example - run: npm ci + run: npm install - name: Write example app .env env: @@ -100,7 +100,7 @@ jobs: set -e adb shell 'getprop net.dns1; getprop net.dns2' || true adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } - cd example/android && ./gradlew assembleDebug && cd ../.. + cd example/android && gradle wrapper && ./gradlew assembleDebug && cd ../.. ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json - name: Upload E2E reports diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index fb9779f7..26f1b0c0 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -108,7 +108,7 @@ jobs: - name: Install example app dependencies working-directory: example - run: npm ci + run: npm install - name: Install CocoaPods working-directory: example/ios diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index dfac9fc9..4c2e1f7a 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -56,7 +56,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '20' - - run: npm ci + - run: npm install - run: npm run lint - run: npm test -- --coverage @@ -77,7 +77,10 @@ jobs: cache: 'gradle' - name: Install example app dependencies working-directory: example - run: npm ci + run: npm install + - name: Generate Gradle wrapper script + working-directory: example/android + run: gradle wrapper - name: Build Android release APK working-directory: example/android run: ./gradlew assembleRelease @@ -94,7 +97,7 @@ jobs: node-version: '20' - name: Install example app dependencies working-directory: example - run: npm ci + run: npm install - name: Install CocoaPods working-directory: example/ios run: pod install diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index f30db76b..d8be8325 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -462,7 +462,7 @@ jobs: node-version: '20' - name: Install dependencies - run: npm ci + run: npm install - name: Validate package (dry-run) run: npm pack --dry-run From e8ad490aaa19fc6b7e7de240c0b863c3351451fb Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Tue, 12 May 2026 16:47:07 +0300 Subject: [PATCH 11/21] chore: Exclude example directory from ESLint Avoids linting the example project, which is not part of the main application's codebase and often has different requirements. --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index b8f1bfe9..595e3b7a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -57,6 +57,7 @@ module.exports = { ignorePatterns: [ 'node_modules/**', + 'example/**', 'demos/**', 'android/**', 'ios/**', From e8569fd19b4d16681b887976d9a53fcb4ee7000b Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Tue, 12 May 2026 17:08:33 +0300 Subject: [PATCH 12/21] ci: Refine automated release and smoke test workflows --- .github/workflows/promote-release.yml | 2 +- .github/workflows/rc-release.yml | 2 +- .github/workflows/rc-smoke.yml | 12 ++++++++++++ example/.eslintrc.js | 4 ---- 4 files changed, 14 insertions(+), 6 deletions(-) delete mode 100644 example/.eslintrc.js diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 222cbc1d..5b794f4a 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -161,7 +161,7 @@ jobs: git config user.email "${CI_COMMIT_EMAIL:-github-actions[bot]@users.noreply.github.com}" if [[ -n $(git status -s) ]]; then - git add package.json package-lock.json ios/RNAppsFlyer.h android/ + git add package.json ios/RNAppsFlyer.h android/ git commit -m "chore: prepare production release $VERSION (from $CURRENT_VERSION)" git push echo "Pushed version update to release branch" diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index d8be8325..f89e3f82 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -305,7 +305,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git config user.name "github-actions[bot]" if [[ -n $(git status -s) ]]; then - git add package.json package-lock.json android/ ios/ react-native-appsflyer.podspec README.md CHANGELOG.md || true + git add package.json android/ ios/ react-native-appsflyer.podspec README.md CHANGELOG.md git commit -m "chore: prepare RC ${VERSION} (iOS ${IOS_VER}, Android ${AND_VER})" git push --set-upstream origin "$REL_BRANCH" else diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml index cfb8169a..078177a4 100644 --- a/.github/workflows/rc-smoke.yml +++ b/.github/workflows/rc-smoke.yml @@ -263,6 +263,17 @@ jobs: done cd ios && pod install + - name: Build iOS (debug) + working-directory: example_rc_smoke/ios + run: | + xcodebuild build \ + -workspace example.xcworkspace \ + -scheme example \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_UDID" \ + -derivedDataPath build \ + | xcpretty || true + - name: Run smoke (SMOKE-001/002/003) run: ./scripts/af-scenario-runner.sh --platform ios --plan .af-smoke/rc-test-plan.json @@ -367,6 +378,7 @@ jobs: set -e adb shell 'getprop net.dns1; getprop net.dns2' || true adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } + cd example_rc_smoke/android && gradle wrapper && cd ../.. cd example_rc_smoke && npx react-native build-android --mode=debug && cd .. ./scripts/af-scenario-runner.sh --platform android --plan .af-smoke/rc-test-plan.json diff --git a/example/.eslintrc.js b/example/.eslintrc.js deleted file mode 100644 index 187894b6..00000000 --- a/example/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: '@react-native', -}; From b1f725a2b0590d1ac02cf4898d105a78d31a4dfa Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 09:45:38 +0300 Subject: [PATCH 13/21] ci: Commit Gradle wrapper scripts for consistent builds Commits the Gradle wrapper (`gradlew` and `gradlew.bat`) to the repository, eliminating the need to generate them dynamically in CI workflows. This change improves build reliability and consistency across different environments. --- .github/workflows/android-e2e.yml | 2 +- .github/workflows/lint-test-build.yml | 3 - .github/workflows/rc-smoke.yml | 2 +- .gitignore | 4 - example/android/gradlew | 248 ++++++++++++++++++++++++++ example/android/gradlew.bat | 98 ++++++++++ 6 files changed, 348 insertions(+), 9 deletions(-) create mode 100755 example/android/gradlew create mode 100644 example/android/gradlew.bat diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index 5574c1ab..b911fbfe 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -100,7 +100,7 @@ jobs: set -e adb shell 'getprop net.dns1; getprop net.dns2' || true adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } - cd example/android && gradle wrapper && ./gradlew assembleDebug && cd ../.. + cd example/android && ./gradlew assembleDebug && cd ../.. ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json - name: Upload E2E reports diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 4c2e1f7a..103f5b42 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -78,9 +78,6 @@ jobs: - name: Install example app dependencies working-directory: example run: npm install - - name: Generate Gradle wrapper script - working-directory: example/android - run: gradle wrapper - name: Build Android release APK working-directory: example/android run: ./gradlew assembleRelease diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml index 078177a4..9b1296a6 100644 --- a/.github/workflows/rc-smoke.yml +++ b/.github/workflows/rc-smoke.yml @@ -378,7 +378,7 @@ jobs: set -e adb shell 'getprop net.dns1; getprop net.dns2' || true adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup oyoxfj.conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } - cd example_rc_smoke/android && gradle wrapper && cd ../.. + cd example_rc_smoke/android && cp ../../example/android/gradlew . && cd ../.. cd example_rc_smoke && npx react-native build-android --mode=debug && cd .. ./scripts/af-scenario-runner.sh --platform android --plan .af-smoke/rc-test-plan.json diff --git a/.gitignore b/.gitignore index d88e222d..77007697 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,8 @@ ios/Podfile.lock .idea .gradle local.properties -android/gradlew.bat .vscode android/gradle -android/gradlew android/.project android/.settings @@ -88,8 +86,6 @@ example/ios/.xcode.env.local example/ios/.af-e2e/ example/android/app/.cxx/ example/android/app/debug.keystore -example/android/gradlew -example/android/gradlew.bat example/package-lock.json # Scenario runner reports diff --git a/example/android/gradlew b/example/android/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/example/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/example/android/gradlew.bat b/example/android/gradlew.bat new file mode 100644 index 00000000..39baf4d6 --- /dev/null +++ b/example/android/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From 2bd0f4729d7c48ebcef5eaf6b79f085bba37ef46 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 10:53:17 +0300 Subject: [PATCH 14/21] ci: Allow concurrent GitHub Actions workflow runs on same branch --- .github/workflows/android-e2e.yml | 2 +- .github/workflows/lint-test-build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index b911fbfe..a8f86f34 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -101,7 +101,7 @@ jobs: adb shell 'getprop net.dns1; getprop net.dns2' || true adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5' || { sleep 10; adb shell 'nslookup conversions.appsflyersdk.com 2>&1 | head -5'; } || { echo "::error::Emulator DNS cannot resolve AppsFlyer hosts"; exit 1; } cd example/android && ./gradlew assembleDebug && cd ../.. - ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json + bash ./scripts/af-scenario-runner.sh --platform android --plan .af-e2e/test-plan.json - name: Upload E2E reports if: always() diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 103f5b42..51125ebf 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -43,7 +43,7 @@ on: default: false concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.run_id }}-${{ github.ref }} cancel-in-progress: true jobs: From 7beae988ef798b6a7ba82fff7826263b9323dcd8 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 11:18:17 +0300 Subject: [PATCH 15/21] ci: Enhance RC release workflow to handle skipped jobs --- .github/workflows/rc-release.yml | 44 ++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index f89e3f82..a0dbd0be 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -410,7 +410,7 @@ jobs: name: Validate Jira Fix Version runs-on: ubuntu-latest needs: [validate-release, pre-publish-gate] - if: needs.validate-release.outputs.is_valid == 'true' && needs.pre-publish-gate.outputs.passed == 'true' + if: always() && needs.pre-publish-gate.result == 'success' && needs.pre-publish-gate.outputs.passed == 'true' steps: - name: Verify Jira fix version exists env: @@ -448,7 +448,7 @@ jobs: name: Publish RC to npm runs-on: ubuntu-latest needs: [validate-release, prepare-branch, pre-publish-gate, validate-jira] - if: needs.validate-release.outputs.is_valid == 'true' && needs.pre-publish-gate.outputs.passed == 'true' + if: always() && needs.pre-publish-gate.result == 'success' && needs.pre-publish-gate.outputs.passed == 'true' && (needs.validate-jira.result == 'success' || needs.validate-jira.result == 'skipped') steps: - name: Checkout repository uses: actions/checkout@v5 @@ -642,12 +642,14 @@ jobs: PRERELEASE_RESULT: ${{ needs.create-prerelease.result }} run: | set -euo pipefail + ok() { [[ "$1" == "success" || "$1" == "skipped" ]]; } + if [[ "$VALIDATE_RESULT" == "success" \ && "$PREPARE_RESULT" == "success" \ - && "$GATE_RESULT" == "success" \ - && "$JIRA_RESULT" == "success" \ - && "$PUBLISH_RESULT" == "success" \ - && "$PRERELEASE_RESULT" == "success" ]]; then + && "$GATE_RESULT" == "success" ]] \ + && ok "$JIRA_RESULT" \ + && ok "$PUBLISH_RESULT" \ + && ok "$PRERELEASE_RESULT"; then echo "success=true" >> "$GITHUB_OUTPUT" echo "failed_stage=" >> "$GITHUB_OUTPUT" exit 0 @@ -659,9 +661,9 @@ jobs: echo "failed_stage=prepare-branch" >> "$GITHUB_OUTPUT" elif [[ "$GATE_RESULT" != "success" ]]; then echo "failed_stage=pre-publish-gate" >> "$GITHUB_OUTPUT" - elif [[ "$JIRA_RESULT" != "success" ]]; then + elif ! ok "$JIRA_RESULT"; then echo "failed_stage=validate-jira" >> "$GITHUB_OUTPUT" - elif [[ "$PUBLISH_RESULT" != "success" ]]; then + elif ! ok "$PUBLISH_RESULT"; then echo "failed_stage=publish-rc" >> "$GITHUB_OUTPUT" else echo "failed_stage=create-prerelease" >> "$GITHUB_OUTPUT" @@ -781,23 +783,23 @@ jobs: fi - name: Send Slack notification (Success) - if: steps.status.outputs.success == 'true' && needs.validate-release.outputs.is_dry_run != 'true' + if: steps.status.outputs.success == 'true' uses: slackapi/slack-github-action@v1 with: payload: | { - "text": "\n:react::react::react::react::react::react::react::react::react::react::react::react:\n\n*React Native Release Candidate:*\nreact-native-appsflyer: ${{ needs.validate-release.outputs.version }} is ready for QA testing.\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n\n*Testing Instructions:*\n```\nnpm install react-native-appsflyer@${{ needs.validate-release.outputs.version }} --save\n```\n\n*Sources:*\n:github: https://github.com/${{ github.repository }}/tree/${{ github.ref_name }}\n:github: Release: https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate-release.outputs.version }}\n:github: Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Changes and fixes:*\n${{ steps.extract-info.outputs.changelog }}\n\n*Linked tickets and issues:*\n${{ steps.jira-tickets.outputs.tickets }}\n\n*Native SDKs:*\n:android: ${{ steps.extract-info.outputs.android_sdk }}\n:apple: ${{ steps.extract-info.outputs.ios_sdk }}\n\n:react::react::react::react::react::react::react::react::react::react::react::react:" + "text": "\n:react::react::react::react::react::react::react::react::react::react::react::react:\n\n${{ needs.validate-release.outputs.is_dry_run == 'true' && ':test_tube: *[DRY RUN]* ' || '' }}*React Native Release Candidate:*\nreact-native-appsflyer: ${{ needs.validate-release.outputs.version }} ${{ needs.validate-release.outputs.is_dry_run == 'true' && 'pipeline completed (not published to npm)' || 'is ready for QA testing' }}.\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n${{ needs.validate-release.outputs.is_dry_run != 'true' && format('\n*Testing Instructions:*\n```\nnpm install react-native-appsflyer@{0} --save\n```', needs.validate-release.outputs.version) || '' }}\n\n*Sources:*\n:github: https://github.com/${{ github.repository }}/tree/${{ github.ref_name }}\n:github: Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n${{ needs.validate-release.outputs.is_dry_run != 'true' && format(':github: Release: https://github.com/{0}/releases/tag/{1}', github.repository, needs.validate-release.outputs.version) || '' }}\n\n*Changes and fixes:*\n${{ steps.extract-info.outputs.changelog }}\n\n*Linked tickets and issues:*\n${{ steps.jira-tickets.outputs.tickets }}\n\n*Native SDKs:*\n:android: ${{ steps.extract-info.outputs.android_sdk }}\n:apple: ${{ steps.extract-info.outputs.ios_sdk }}\n\n:react::react::react::react::react::react::react::react::react::react::react::react:" } env: SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} - name: Send failure notification - if: steps.status.outputs.success == 'false' && needs.validate-release.outputs.is_dry_run != 'true' + if: steps.status.outputs.success == 'false' uses: slackapi/slack-github-action@v1 with: payload: | { - "text": "\n:warning: *React Native RC failed at `${{ steps.status.outputs.failed_stage }}`*\n\nVersion: ${{ needs.validate-release.outputs.version }}\nBranch: ${{ github.ref_name }}\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n\n*Downstream stages:*\n- prepare-branch: ${{ needs.prepare-branch.result }}\n- pre-publish-gate: ${{ needs.pre-publish-gate.result }}\n- validate-jira: ${{ needs.validate-jira.result }}\n- publish-rc: ${{ needs.publish-rc.result }}\n- create-prerelease: ${{ needs.create-prerelease.result }}" + "text": "\n:warning: *${{ needs.validate-release.outputs.is_dry_run == 'true' && '[DRY RUN] ' || '' }}React Native RC failed at `${{ steps.status.outputs.failed_stage }}`*\n\nVersion: ${{ needs.validate-release.outputs.version }}\nBranch: ${{ github.ref_name }}\nRun: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n\n*Pre-publish checks:*\n${{ steps.legs.outputs.ci_icon }} Lint, Test & Build: ${{ steps.legs.outputs.ci_result }}\n${{ steps.legs.outputs.ios_icon }} RC-E2E iOS: ${{ steps.legs.outputs.e2e_ios_result }}\n${{ steps.legs.outputs.android_icon }} RC-E2E Android: ${{ steps.legs.outputs.e2e_android_result }}\n\n*Downstream stages:*\n- prepare-branch: ${{ needs.prepare-branch.result }}\n- pre-publish-gate: ${{ needs.pre-publish-gate.result }}\n- validate-jira: ${{ needs.validate-jira.result }}\n- publish-rc: ${{ needs.publish-rc.result }}\n- create-prerelease: ${{ needs.create-prerelease.result }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.CI_SLACK_HOOK }} @@ -832,10 +834,20 @@ jobs: echo "Pre-release tag + PR: ${{ needs.create-prerelease.result }}" echo "=========================================" - if [[ "${{ needs.validate-release.result }}" == "success" ]] && \ - [[ "${{ needs.pre-publish-gate.result }}" == "success" ]] && \ - [[ "${{ needs.publish-rc.result }}" == "success" ]] && \ - [[ "${{ needs.create-prerelease.result }}" == "success" ]]; then + ok() { [[ "$1" == "success" || "$1" == "skipped" ]]; } + + FAIL=0 + # validate-release and pre-publish-gate must succeed (never legitimately skipped) + [[ "${{ needs.validate-release.result }}" == "success" ]] || FAIL=1 + [[ "${{ needs.pre-publish-gate.result }}" == "success" ]] || FAIL=1 + # downstream jobs may be skipped (dry-run, skip_e2e, etc.) + ok "${{ needs.run-ci.result }}" || FAIL=1 + ok "${{ needs.prepare-branch.result }}" || FAIL=1 + ok "${{ needs.validate-jira.result }}" || FAIL=1 + ok "${{ needs.publish-rc.result }}" || FAIL=1 + ok "${{ needs.create-prerelease.result }}" || FAIL=1 + + if [[ "$FAIL" == "0" ]]; then echo "RC Release Process Completed Successfully" else echo "RC Release Process Failed" From 0e0576cde4725a7a37b76441877bd793db224a99 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 12:11:26 +0300 Subject: [PATCH 16/21] ci: Enhance E2E test reliability and CI/CD stability --- .af-e2e/test-plan.json | 10 ++++---- .github/workflows/ios-e2e.yml | 2 +- .github/workflows/lint-test-build.yml | 2 +- .github/workflows/promote-release.yml | 5 ++-- .github/workflows/rc-release.yml | 5 +++- .github/workflows/rc-smoke.yml | 2 +- .../reactnative/AfQaNativeLoggerModule.java | 25 +++++++++++++++++++ .../reactnative/AfQaNativeLoggerPackage.java | 25 +++++++++++++++++++ .../qa/reactnative/MainApplication.kt | 3 +-- example/src/AfQaLogger.ts | 18 ++++++------- 10 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerModule.java create mode 100644 example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerPackage.java diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index e8b42f59..44f65b2c 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -30,7 +30,7 @@ "scenario_ref": "E2E-001", "description": "Fresh install. Validate SDK startup, pre/post-start APIs, three auto-launched events, and install conversion data callback.", "requires_fresh_install": true, - "wait_after_launch_sec": 30, + "wait_after_launch_sec": 420, "checks": [ { "id": "sdk_started", @@ -125,7 +125,7 @@ "scenario_ref": "E2E-003", "description": "Fresh install, SDK starts, then deep link while app is in foreground. Verifies both SDK start and deep link callback fire correctly.", "requires_fresh_install": true, - "wait_after_launch_sec": 30, + "wait_after_launch_sec": 420, "wait_after_trigger_sec": 15, "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", "pre_actions": { @@ -174,7 +174,7 @@ "scenario_ref": "E2E-004", "description": "Validates logEvent API with multiple event types and params. Verifies af_demo_launch, af_purchase, and af_content_view all return success.", "requires_fresh_install": false, - "wait_after_launch_sec": 30, + "wait_after_launch_sec": 420, "checks": [ { "id": "sdk_started", @@ -228,7 +228,7 @@ "scenario_ref": "E2E-005", "description": "Validates setCustomerUserId, getAppsFlyerUID, and getSDKVersion readbacks after SDK start.", "requires_fresh_install": false, - "wait_after_launch_sec": 30, + "wait_after_launch_sec": 420, "checks": [ { "id": "sdk_started", @@ -274,7 +274,7 @@ "scenario_ref": "E2E-006", "description": "Validates setSharingFilterForPartners and setConsentData readbacks. stop(true)/stop(false) cycle requires HTTP traffic inspection (future).", "requires_fresh_install": false, - "wait_after_launch_sec": 30, + "wait_after_launch_sec": 420, "checks": [ { "id": "sdk_started", diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 26f1b0c0..c0ddae3d 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -135,7 +135,7 @@ jobs: -configuration Debug \ -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_UDID" \ -derivedDataPath build \ - | xcpretty || true + | xcpretty - name: Run E2E (af-scenario-runner against .af-e2e/test-plan.json) run: ./scripts/af-scenario-runner.sh --platform ios --plan .af-e2e/test-plan.json diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml index 51125ebf..7ef9ed9a 100644 --- a/.github/workflows/lint-test-build.yml +++ b/.github/workflows/lint-test-build.yml @@ -107,7 +107,7 @@ jobs: -configuration Release \ -destination 'generic/platform=iOS' \ CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ - | xcpretty || true + | xcpretty ci-summary: name: CI Summary diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 5b794f4a..315120a2 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -133,7 +133,7 @@ jobs: # 1. package.json (also updates react-native-appsflyer.podspec # since podspec reads s.version from package.json) - npm version "$VERSION" --no-git-tag-version + npm version "$VERSION" --no-git-tag-version --allow-same-version echo "package.json:" && node -p "require('./package.json').version" # 2. ios/RNAppsFlyer.h — kAppsFlyerPluginVersion @@ -161,7 +161,8 @@ jobs: git config user.email "${CI_COMMIT_EMAIL:-github-actions[bot]@users.noreply.github.com}" if [[ -n $(git status -s) ]]; then - git add package.json ios/RNAppsFlyer.h android/ + git add package.json react-native-appsflyer.podspec ios/RNAppsFlyer.h \ + android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java git commit -m "chore: prepare production release $VERSION (from $CURRENT_VERSION)" git push echo "Pushed version update to release branch" diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index a0dbd0be..15aeef14 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -305,7 +305,10 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git config user.name "github-actions[bot]" if [[ -n $(git status -s) ]]; then - git add package.json android/ ios/ react-native-appsflyer.podspec README.md CHANGELOG.md + git add -f package.json react-native-appsflyer.podspec README.md CHANGELOG.md \ + ios/RNAppsFlyer.h \ + android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerConstants.java \ + android/build.gradle git commit -m "chore: prepare RC ${VERSION} (iOS ${IOS_VER}, Android ${AND_VER})" git push --set-upstream origin "$REL_BRANCH" else diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml index 9b1296a6..7683dcba 100644 --- a/.github/workflows/rc-smoke.yml +++ b/.github/workflows/rc-smoke.yml @@ -272,7 +272,7 @@ jobs: -configuration Debug \ -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_UDID" \ -derivedDataPath build \ - | xcpretty || true + | xcpretty - name: Run smoke (SMOKE-001/002/003) run: ./scripts/af-scenario-runner.sh --platform ios --plan .af-smoke/rc-test-plan.json diff --git a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerModule.java b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerModule.java new file mode 100644 index 00000000..83c83790 --- /dev/null +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerModule.java @@ -0,0 +1,25 @@ +package com.appsflyer.qa.reactnative; + +import android.util.Log; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; + +public class AfQaNativeLoggerModule extends ReactContextBaseJavaModule { + + private static final String TAG = "AF_QA"; + + AfQaNativeLoggerModule(ReactApplicationContext context) { + super(context); + } + + @Override + public String getName() { + return "AfQaNativeLogger"; + } + + @ReactMethod + public void log(String message) { + Log.d(TAG, message); + } +} diff --git a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerPackage.java b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerPackage.java new file mode 100644 index 00000000..708056db --- /dev/null +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/AfQaNativeLoggerPackage.java @@ -0,0 +1,25 @@ +package com.appsflyer.qa.reactnative; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class AfQaNativeLoggerPackage implements ReactPackage { + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new AfQaNativeLoggerModule(reactContext)); + return modules; + } + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } +} diff --git a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt index 2e5982ee..32d63622 100644 --- a/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt @@ -14,8 +14,7 @@ class MainApplication : Application(), ReactApplication { context = applicationContext, packageList = PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + add(AfQaNativeLoggerPackage()) }, ) } diff --git a/example/src/AfQaLogger.ts b/example/src/AfQaLogger.ts index 551176d9..e8b670d7 100644 --- a/example/src/AfQaLogger.ts +++ b/example/src/AfQaLogger.ts @@ -2,15 +2,15 @@ import {NativeModules, Platform} from 'react-native'; const LOG_TAG = '[AF_QA]'; -// On iOS, console.log goes only to Metro (not os_log). Use a native module -// so logs appear in `simctl log show` for the scenario runner to capture. -const nativeLog = - Platform.OS === 'ios' && NativeModules.AfQaNativeLogger - ? (msg: string) => { - NativeModules.AfQaNativeLogger.log(msg); - console.log(msg); - } - : (msg: string) => console.log(msg); +// console.log doesn't reliably reach platform log collectors (os_log on iOS, +// logcat on Android) when running without Metro. Use the native module on both +// platforms so the scenario runner can always capture [AF_QA] markers. +const nativeLog = NativeModules.AfQaNativeLogger + ? (msg: string) => { + NativeModules.AfQaNativeLogger.log(msg); + console.log(msg); + } + : (msg: string) => console.log(msg); export function afLog(method: string, message: string): void { nativeLog(`${LOG_TAG}[${method}] ${message}`); From 1065dfbc034749b62a9a81a685447c11ee5eed99 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 12:38:13 +0300 Subject: [PATCH 17/21] ci: Improve CI build consistency and test log retrieval --- .github/workflows/ios-e2e.yml | 2 ++ .github/workflows/rc-smoke.yml | 2 ++ example/android/app/build.gradle | 3 +++ scripts/af-scenario-runner.sh | 9 +++++++-- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index c0ddae3d..35efb9d8 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -128,6 +128,8 @@ jobs: - name: Build iOS simulator app (debug) working-directory: example/ios + env: + FORCE_BUNDLING: "1" run: | xcodebuild build \ -workspace example.xcworkspace \ diff --git a/.github/workflows/rc-smoke.yml b/.github/workflows/rc-smoke.yml index 7683dcba..c0bd9806 100644 --- a/.github/workflows/rc-smoke.yml +++ b/.github/workflows/rc-smoke.yml @@ -265,6 +265,8 @@ jobs: - name: Build iOS (debug) working-directory: example_rc_smoke/ios + env: + FORCE_BUNDLING: "1" run: | xcodebuild build \ -workspace example.xcworkspace \ diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 4fe51977..9c6dc253 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -23,6 +23,9 @@ react { // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] + if (System.getenv("CI") != null) { + debuggableVariants = [] + } /* Bundling */ // A list containing the node command and its flags. Default is just 'node'. diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index fb3235b4..1437c2ab 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -443,6 +443,7 @@ platform_peek_qa_log() { for path in app_flutter/af_qa_logs.txt files/af_qa_logs.txt; do adb shell "run-as $PACKAGE_NAME cat $path 2>/dev/null" 2>/dev/null && return 0 done + adb logcat -d 2>/dev/null | grep -F "$LOG_TAG" 2>/dev/null || true return 0 fi ios_ensure_udid @@ -452,8 +453,12 @@ platform_peek_qa_log() { local qa_log qa_log=$(find "$sim_data_dir/Containers/Data/Application" \ -name "af_qa_logs.txt" -maxdepth 4 2>/dev/null | head -1) - [[ -n "$qa_log" && -f "$qa_log" ]] || return 0 - cat "$qa_log" 2>/dev/null || true + if [[ -n "$qa_log" && -f "$qa_log" ]]; then + cat "$qa_log" 2>/dev/null || true + return 0 + fi + xcrun simctl spawn "$IOS_UDID" log show --last 2m --predicate "messageType == default || messageType == info || messageType == debug" 2>/dev/null \ + | grep -F "$LOG_TAG" 2>/dev/null || true } # wait_for_qa_marker [interval_sec] From e80e52f15270f88cc207d1e9b61f9bd4d14d4cc0 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 14:27:24 +0300 Subject: [PATCH 18/21] ci: Enhance NPM publishing security with OIDC Migrates the npm publish steps in the production and RC release workflows to use OpenID Connect (OIDC) for authentication. This removes the reliance on a long-lived npm token secret, improving security by leveraging short-lived, ephemeral credentials. --- .github/workflows/production-release.yml | 7 ++++--- .github/workflows/rc-release.yml | 14 +++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml index 68c829a4..124d4c26 100644 --- a/.github/workflows/production-release.yml +++ b/.github/workflows/production-release.yml @@ -156,6 +156,9 @@ jobs: runs-on: ubuntu-latest needs: [validate-release] if: needs.validate-release.result == 'success' && needs.validate-release.outputs.is_valid == 'true' + permissions: + contents: read + id-token: write steps: - name: Checkout repository @@ -165,17 +168,15 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 + registry-url: 'https://registry.npmjs.org' - name: Publish to npm if: needs.validate-release.outputs.is_dry_run != 'true' env: - CI_NPM_TOKEN: ${{ secrets.CI_NPM_TOKEN }} VERSION: ${{ needs.validate-release.outputs.version }} run: | echo "Publishing version $VERSION to npm..." - echo "//registry.npmjs.org/:_authToken=$CI_NPM_TOKEN" > ~/.npmrc npm publish - rm ~/.npmrc echo "Published $VERSION to npm" - name: Dry-run publish diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index 15aeef14..1b37087c 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -452,6 +452,9 @@ jobs: runs-on: ubuntu-latest needs: [validate-release, prepare-branch, pre-publish-gate, validate-jira] if: always() && needs.pre-publish-gate.result == 'success' && needs.pre-publish-gate.outputs.passed == 'true' && (needs.validate-jira.result == 'success' || needs.validate-jira.result == 'skipped') + permissions: + contents: read + id-token: write steps: - name: Checkout repository uses: actions/checkout@v5 @@ -463,6 +466,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' + registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm install @@ -477,15 +481,7 @@ jobs: - name: Publish RC to npm if: ${{ needs.validate-release.outputs.is_dry_run != 'true' }} - env: - CI_NPM_TOKEN: ${{ secrets.CI_NPM_TOKEN }} - run: | - if [[ -z "${CI_NPM_TOKEN}" ]]; then - echo "CI_NPM_TOKEN is missing"; exit 1 - fi - echo "//registry.npmjs.org/:_authToken=${CI_NPM_TOKEN}" > ~/.npmrc - npm publish --tag rc - rm -f ~/.npmrc + run: npm publish --tag rc # =========================================================================== # Create Pre-Release Tag From 41359204bb30ed816e3a391b3de1fc2ad4e662a1 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 15:03:23 +0300 Subject: [PATCH 19/21] ci: Enhance React Native E2E test plan for comprehensive coverage Refactors and expands the React Native E2E test suite (v1.0.0 to v1.1.0) with more granular validation for critical SDK functionalities. Key enhancements include: * Detailed checks for cold launch, background/foreground deep links, custom events with rich parameters, and identity API round-trips. * A comprehensive test for the `stop()` API, verifying event suppression and resumption. * Updates the example app (`App.tsx`) and adds `getCustomerUserId` to native/JS interfaces to enable these new test scenarios. This significantly improves pre-publish validation and overall plugin reliability. --- .af-e2e/test-plan.json | 276 +++++++++++------- .../reactnative/RNAppsFlyerModule.java | 7 + example/src/App.tsx | 76 ++++- index.d.ts | 1 + index.js | 9 + ios/RNAppsFlyer.m | 5 + 6 files changed, 272 insertions(+), 102 deletions(-) diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index 44f65b2c..e22ce383 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -2,7 +2,7 @@ "_meta": { "plan_id": "reactnative-e2e", "plugin": "reactnative", - "version": "1.0.0", + "version": "1.1.0", "description": "Pre-publish E2E test plan for the AppsFlyer React Native plugin. Runs against plugin source in example/. Covers six scenarios: cold launch, background deep link, foreground deep link, custom events, identity APIs, and consent/stop. Mapped to E2E-001..E2E-006 in appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md.", "platforms": ["android", "ios"], "schema_version": "1.0.0", @@ -28,51 +28,94 @@ "id": "phase_1", "name": "Cold launch coverage", "scenario_ref": "E2E-001", - "description": "Fresh install. Validate SDK startup, pre/post-start APIs, three auto-launched events, and install conversion data callback.", + "description": "Fresh install. Validate SDK startup, pre/post-start APIs, three auto-launched events with HTTP 200, install conversion data with is_first_launch=true, and onDeepLinking NOT_FOUND on clean launch.", "requires_fresh_install": true, "wait_after_launch_sec": 420, "checks": [ { "id": "sdk_started", - "description": "startSDK returns a result", + "description": "startSDK was called", "type": "log_contains", "pattern": "[AF_QA][startSDK] result:", "fail_action": "abort" }, { - "id": "pre_start_complete", + "id": "is_first_launch_true", + "description": "onInstallConversionData fires with is_first_launch=true", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "payload_check": {"field": "is_first_launch", "expected": "true"}, + "fail_action": "abort" + }, + { + "id": "pre_start_apis_complete", "description": "Pre-start auto APIs ran", "type": "log_contains", "pattern": "[AF_QA][AUTO_APIS] --- Pre-start auto APIs complete ---", "fail_action": "fail" }, { - "id": "post_start_complete", + "id": "post_start_apis_complete", "description": "Post-start auto APIs ran", "type": "log_contains", "pattern": "[AF_QA][AUTO_APIS] --- Post-start auto APIs complete ---", "fail_action": "fail" }, { - "id": "conversion_data", - "description": "onInstallConversionData callback fires", + "id": "get_sdk_version", + "description": "getSDKVersion returns a value", "type": "log_contains", - "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", - "fail_action": "warn" + "pattern": "[AF_QA][getSDKVersion] result:", + "fail_action": "fail" + }, + { + "id": "get_appsflyer_uid", + "description": "getAppsFlyerUID returns a value", + "type": "log_contains", + "pattern": "[AF_QA][getAppsFlyerUID] result:", + "fail_action": "fail" + }, + { + "id": "event_af_demo_launch", + "description": "af_demo_launch event fires successfully", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_demo_launch)] result:", + "fail_action": "fail" + }, + { + "id": "event_af_purchase", + "description": "af_purchase event fires successfully", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_purchase)] result:", + "fail_action": "fail" + }, + { + "id": "event_af_content_view", + "description": "af_content_view event fires successfully", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_content_view)] result:", + "fail_action": "fail" }, { - "id": "events_fired", - "description": "At least 3 logEvent calls recorded", + "id": "http_200_count", + "description": "At least 3 HTTP 200 responses from AppsFlyer servers", "type": "count_matches", - "pattern": "\\[AF_QA\\]\\[logEvent", + "pattern": "response code:200 OK|response_status=200", "minimum": 3, "fail_action": "fail" }, { - "id": "no_fatal", - "description": "No fatal exceptions or process crashes in logs", + "id": "on_deep_linking_callback", + "description": "onDeepLinking fires (NOT_FOUND expected on clean launch)", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onDeepLinking]", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions or SDK errors in logs", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL", "[AF_QA][startSDK] error:", "response code:4", "response code:5"], "fail_action": "fail" } ] @@ -82,13 +125,13 @@ "id": "phase_2", "name": "Background deep link", "scenario_ref": "E2E-002", - "description": "App backgrounded after Phase 1, then deep link triggers re-entry. onDeepLinking fires with the expected deep link value.", + "description": "App backgrounded after Phase 1, then deep link triggers re-entry. onDeepLinking fires with Status.FOUND and correct deepLinkValue. LAUNCH event receives HTTP 200.", "requires_fresh_install": false, "wait_after_trigger_sec": 15, "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_bg&af_sub1=background_test&pid=testmedia&c=deeplink_test", "pre_actions": { "android": ["adb shell input keyevent KEYCODE_HOME", "sleep 2"], - "ios": ["xcrun simctl terminate {{UDID}} {{BUNDLE_ID}}", "sleep 2", "xcrun simctl launch {{UDID}} {{BUNDLE_ID}}", "sleep 3"] + "ios": ["xcrun simctl launch {{UDID}} com.apple.mobilesafari", "sleep 2"] }, "trigger": { "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", @@ -96,24 +139,32 @@ }, "checks": [ { - "id": "deep_link_received", - "description": "onDeepLinking callback fires", + "id": "deeplink_status_found", + "description": "onDeepLinking fires with Status.FOUND", "type": "log_contains", - "pattern": "[AF_QA][CALLBACK][onDeepLinking]", - "fail_action": "warn" + "pattern": "status=FOUND", + "fail_action": "fail" }, { - "id": "deep_link_value", - "description": "Deep link value matches qa_deeplink_bg", + "id": "deeplink_value_bg", + "description": "deepLinkValue matches qa_deeplink_bg", "type": "log_contains", - "pattern": "qa_deeplink_bg", - "fail_action": "warn" + "pattern": "deepLinkValue=qa_deeplink_bg", + "fail_action": "fail" }, { - "id": "no_fatal", + "id": "launch_http_200", + "description": "LAUNCH event receives HTTP 200 after deep link re-entry", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 1, + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", "description": "No fatal exceptions after deep link", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL"], "fail_action": "fail" } ] @@ -123,14 +174,14 @@ "id": "phase_3", "name": "Foreground deep link", "scenario_ref": "E2E-003", - "description": "Fresh install, SDK starts, then deep link while app is in foreground. Verifies both SDK start and deep link callback fire correctly.", + "description": "Fresh install. App in foreground after SDK start. Brief launcher switch, then deep link. onDeepLinking fires with Status.FOUND and correct deepLinkValue. Conversion data has is_first_launch=true.", "requires_fresh_install": true, "wait_after_launch_sec": 420, "wait_after_trigger_sec": 15, "deep_link_url": "afqa-reactnative://deeplink?deep_link_value=qa_deeplink_fg&af_sub1=foreground_test&pid=testmedia&c=deeplink_test", "pre_actions": { "android": ["adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME", "sleep 1"], - "ios": ["sleep 3"] + "ios": ["xcrun simctl launch {{UDID}} com.apple.Preferences", "sleep 1"] }, "trigger": { "android": "adb shell am start -W -a android.intent.action.VIEW -d \"{{DEEP_LINK_URL}}\"", @@ -139,30 +190,38 @@ "checks": [ { "id": "sdk_started", - "description": "startSDK returns a result on fresh install", + "description": "startSDK was called on fresh install", "type": "log_contains", "pattern": "[AF_QA][startSDK] result:", "fail_action": "abort" }, { - "id": "deep_link_received", - "description": "onDeepLinking callback fires after foreground deep link", + "id": "is_first_launch_true", + "description": "onInstallConversionData fires with is_first_launch=true", "type": "log_contains", - "pattern": "[AF_QA][CALLBACK][onDeepLinking]", - "fail_action": "warn" + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "payload_check": {"field": "is_first_launch", "expected": "true"}, + "fail_action": "abort" + }, + { + "id": "deeplink_status_found", + "description": "onDeepLinking fires with Status.FOUND after foreground deep link", + "type": "log_contains", + "pattern": "status=FOUND", + "fail_action": "fail" }, { - "id": "deep_link_value", - "description": "Deep link value matches qa_deeplink_fg", + "id": "deeplink_value_fg", + "description": "deepLinkValue matches qa_deeplink_fg", "type": "log_contains", - "pattern": "qa_deeplink_fg", - "fail_action": "warn" + "pattern": "deepLinkValue=qa_deeplink_fg", + "fail_action": "fail" }, { - "id": "no_fatal", + "id": "no_fatal_errors", "description": "No fatal exceptions", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL"], "fail_action": "fail" } ] @@ -170,53 +229,46 @@ { "id": "phase_4", - "name": "Custom events", + "name": "Custom in-app event with parameters", "scenario_ref": "E2E-004", - "description": "Validates logEvent API with multiple event types and params. Verifies af_demo_launch, af_purchase, and af_content_view all return success.", + "description": "Trigger af_qa_custom_purchase with rich parameters (string, number, bool, nested map). Verify all parameter keys are serialized, HTTP 200 returned, and no logEvent errors.", "requires_fresh_install": false, "wait_after_launch_sec": 420, "checks": [ { - "id": "sdk_started", - "description": "startSDK returns a result", + "id": "custom_event_logged", + "description": "af_qa_custom_purchase logEvent fires", "type": "log_contains", - "pattern": "[AF_QA][startSDK] result:", - "fail_action": "abort" - }, - { - "id": "event_demo_launch", - "description": "af_demo_launch event fires successfully", - "type": "log_contains", - "pattern": "[AF_QA][logEvent(af_demo_launch)] result:", + "pattern": "[AF_QA][logEvent(af_qa_custom_purchase)]", "fail_action": "fail" }, { - "id": "event_purchase", - "description": "af_purchase event fires successfully", + "id": "params_complete", + "description": "All 5 parameter keys present in the custom event log line", "type": "log_contains", - "pattern": "[AF_QA][logEvent(af_purchase)] result:", + "pattern": "[AF_QA][logEvent] name=af_qa_custom_purchase params=", "fail_action": "fail" }, { - "id": "event_content_view", - "description": "af_content_view event fires successfully", - "type": "log_contains", - "pattern": "[AF_QA][logEvent(af_content_view)] result:", + "id": "http_200_event", + "description": "HTTP 200 for the custom event", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 1, "fail_action": "fail" }, { - "id": "events_count", - "description": "At least 3 logEvent calls recorded", - "type": "count_matches", - "pattern": "\\[AF_QA\\]\\[logEvent", - "minimum": 3, + "id": "no_logEvent_error", + "description": "No logEvent errors for custom purchase", + "type": "absent", + "patterns": ["[AF_QA][logEvent(af_qa_custom_purchase)] error:"], "fail_action": "fail" }, { - "id": "no_fatal", + "id": "no_fatal_errors", "description": "No fatal exceptions or process crashes", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL"], "fail_action": "fail" } ] @@ -224,19 +276,12 @@ { "id": "phase_5", - "name": "Identity APIs", + "name": "Identity APIs round-trip", "scenario_ref": "E2E-005", - "description": "Validates setCustomerUserId, getAppsFlyerUID, and getSDKVersion readbacks after SDK start.", - "requires_fresh_install": false, + "description": "Fresh install. Verify setCustomerUserId, getCustomerUserId readback, setCurrencyCode, setAdditionalData propagate correctly. Identity-check event receives HTTP 200. is_first_launch=true still fires.", + "requires_fresh_install": true, "wait_after_launch_sec": 420, "checks": [ - { - "id": "sdk_started", - "description": "startSDK returns a result", - "type": "log_contains", - "pattern": "[AF_QA][startSDK] result:", - "fail_action": "abort" - }, { "id": "customer_user_id_set", "description": "setCustomerUserId readback present", @@ -245,24 +290,47 @@ "fail_action": "fail" }, { - "id": "appsflyer_uid", - "description": "getAppsFlyerUID returns a value", + "id": "customer_user_id_readback", + "description": "getCustomerUserId returns the set value", "type": "log_contains", - "pattern": "[AF_QA][getAppsFlyerUID] result:", + "pattern": "[AF_QA][getCustomerUserId] result:", "fail_action": "fail" }, { - "id": "sdk_version", - "description": "getSDKVersion returns a value", + "id": "currency_code", + "description": "setCurrencyCode readback present", "type": "log_contains", - "pattern": "[AF_QA][getSDKVersion] result:", + "pattern": "[AF_QA][setCurrencyCode] result:", + "fail_action": "fail" + }, + { + "id": "additional_data", + "description": "setAdditionalData readback present", + "type": "log_contains", + "pattern": "[AF_QA][setAdditionalData] result:", + "fail_action": "fail" + }, + { + "id": "http_200_identity_event", + "description": "HTTP 200 for the identity-check event", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 1, + "fail_action": "fail" + }, + { + "id": "is_first_launch_true", + "description": "onInstallConversionData still fires with is_first_launch=true", + "type": "log_contains", + "pattern": "[AF_QA][CALLBACK][onInstallConversionData]", + "payload_check": {"field": "is_first_launch", "expected": "true"}, "fail_action": "fail" }, { - "id": "no_fatal", + "id": "no_fatal_errors", "description": "No fatal exceptions or process crashes", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL"], "fail_action": "fail" } ] @@ -270,38 +338,45 @@ { "id": "phase_6", - "name": "Consent and sharing", + "name": "Consent / SDK stop toggle", "scenario_ref": "E2E-006", - "description": "Validates setSharingFilterForPartners and setConsentData readbacks. stop(true)/stop(false) cycle requires HTTP traffic inspection (future).", + "description": "stop(true) suppresses outbound events. stop(false) resumes them. Verify suppressed event does NOT get HTTP 200, resumed event DOES get HTTP 200.", "requires_fresh_install": false, "wait_after_launch_sec": 420, "checks": [ { - "id": "sdk_started", - "description": "startSDK returns a result", + "id": "stop_true", + "description": "stop(true) readback present", "type": "log_contains", - "pattern": "[AF_QA][startSDK] result:", - "fail_action": "abort" + "pattern": "[AF_QA][stop] result: true", + "fail_action": "fail" + }, + { + "id": "suppressed_event_no_result", + "description": "Suppressed event does not get a success result while SDK is stopped", + "type": "absent", + "patterns": ["[AF_QA][logEvent(af_qa_suppressed)] result:"], + "fail_action": "fail" }, { - "id": "sharing_filter", - "description": "setSharingFilterForPartners readback present", + "id": "stop_false", + "description": "stop(false) readback present", "type": "log_contains", - "pattern": "[AF_QA][setSharingFilterForPartners] result: [partner_test]", + "pattern": "[AF_QA][stop] result: false", "fail_action": "fail" }, { - "id": "consent_data", - "description": "setConsentData GDPR consent readback present", + "id": "resumed_event_http_200", + "description": "Resumed event fires and receives HTTP 200", "type": "log_contains", - "pattern": "[AF_QA][setConsentData] result: GDPR consent set", + "pattern": "[AF_QA][logEvent(af_qa_resumed)] result:", "fail_action": "fail" }, { - "id": "no_fatal", - "description": "No fatal exceptions or process crashes", + "id": "no_fatal_errors", + "description": "No fatal exceptions throughout stop/resume cycle", "type": "absent", - "patterns": ["FATAL EXCEPTION", "Process crashed"], + "patterns": ["Fatal Exception", "FATAL"], "fail_action": "fail" } ] @@ -309,6 +384,7 @@ ], "report": { - "output_dir": ".af-e2e/reports" + "output_dir": ".af-e2e/reports", + "format": "json" } } diff --git a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java index 5b0df5ae..ea69fd9b 100755 --- a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java +++ b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java @@ -488,6 +488,13 @@ public void updateServerUninstallToken(final String token, Callback callback) { guardedCallback.invoke(SUCCESS); } + @ReactMethod + public void getCustomerUserId(Callback callback) { + CallbackGuard guardedCallback = new CallbackGuard(callback); + String userId = AppsFlyerLib.getInstance().getCustomerUserId(); + guardedCallback.invoke(null, userId != null ? userId : ""); + } + @ReactMethod public void setCustomerUserId(final String userId, Callback callback) { CallbackGuard guardedCallback = new CallbackGuard(callback); diff --git a/example/src/App.tsx b/example/src/App.tsx index 2d8ef0c1..4771c22d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -49,6 +49,13 @@ function runAutoFlow() { afLog('setCurrencyCode', `result: ${result}`); }); + appsFlyer.setAdditionalData( + {tenant: 'qa_eu', experiment: 'rc_pipeline_v1'}, + result => { + afLog('setAdditionalData', `result: ${result}`); + }, + ); + afLifecycleLog('--- Pre-start auto APIs complete ---'); // 3. Start SDK with manualStart — initSdk configures, startSdk fires the actual start @@ -82,6 +89,10 @@ function runAutoFlow() { afLog('getSDKVersion', `result: ${version || err}`); }); + appsFlyer.getCustomerUserId((err, userId) => { + afLog('getCustomerUserId', `result: ${userId || err}`); + }); + afLifecycleLog('--- Post-start auto APIs complete ---'); // 5. Fire standard events (Promise API — Android CallbackGuard WeakReference @@ -108,7 +119,39 @@ function runAutoFlow() { .then((result: any) => afLog('logEvent(af_content_view)', `result: ${result}`)) .catch((error: any) => afLog('logEvent(af_content_view)', `error: ${error}`)); - // 6. Consent & sharing APIs + // 6. Custom event with rich params (E2E-004) + const customPurchaseParams = { + af_revenue: 19.99, + af_currency: 'USD', + af_content_id: 'sku_42', + is_promo: true, + metadata: {campaign: 'rc_e2e', tier: 'gold'}, + }; + afLog( + 'logEvent', + `name=af_qa_custom_purchase params=${JSON.stringify(customPurchaseParams)}`, + ); + appsFlyer + .logEvent('af_qa_custom_purchase', customPurchaseParams) + .then((result: any) => + afLog('logEvent(af_qa_custom_purchase)', `result: ${result}`), + ) + .catch((error: any) => + afLog('logEvent(af_qa_custom_purchase)', `error: ${error}`), + ); + + // 7. Identity-check event (E2E-005) + afLog('logEvent', `name=af_qa_identity_check params=${JSON.stringify({step: 'post_start'})}`); + appsFlyer + .logEvent('af_qa_identity_check', {step: 'post_start'}) + .then((result: any) => + afLog('logEvent(af_qa_identity_check)', `result: ${result}`), + ) + .catch((error: any) => + afLog('logEvent(af_qa_identity_check)', `error: ${error}`), + ); + + // 8. Consent & sharing APIs appsFlyer.setSharingFilterForPartners(['partner_test']); afLog('setSharingFilterForPartners', 'result: [partner_test]'); @@ -116,7 +159,36 @@ function runAutoFlow() { appsFlyer.setConsentData(consent); afLog('setConsentData', 'result: GDPR consent set'); - afLifecycleLog('--- Auto run complete ---'); + // 9. Stop/resume cycle (E2E-006) + appsFlyer.stop(true, () => { + afLog('stop', 'result: true'); + + appsFlyer + .logEvent('af_qa_suppressed', {phase: 'stopped'}) + .then((result: any) => + afLog('logEvent(af_qa_suppressed)', `result: ${result}`), + ) + .catch((error: any) => + afLog('logEvent(af_qa_suppressed)', `error: ${error}`), + ); + + setTimeout(() => { + appsFlyer.stop(false, () => { + afLog('stop', 'result: false'); + + appsFlyer + .logEvent('af_qa_resumed', {phase: 'restarted'}) + .then((result: any) => + afLog('logEvent(af_qa_resumed)', `result: ${result}`), + ) + .catch((error: any) => + afLog('logEvent(af_qa_resumed)', `error: ${error}`), + ); + + afLifecycleLog('--- Auto run complete ---'); + }); + }, 3000); + }); } const styles = StyleSheet.create({ diff --git a/index.d.ts b/index.d.ts index 412ce598..ad8842fd 100644 --- a/index.d.ts +++ b/index.d.ts @@ -312,6 +312,7 @@ declare module "react-native-appsflyer" { setAdditionalData(additionalData: object, successC?: SuccessCB): void; getAppsFlyerUID(callback: (error: Error, uid: string) => any): void; getSDKVersion(callback: (error: Error, version: string) => any): void; + getCustomerUserId(callback: (error: Error | null, userId: string | null) => void): void; setCustomerUserId(userId: string, successC?: SuccessCB): void; stop(isStopped: boolean, successC?: SuccessCB): void; setAppInviteOneLinkID(oneLinkID: string, successC?: SuccessCB): void; diff --git a/index.js b/index.js index ba179c06..8be9ee52 100755 --- a/index.js +++ b/index.js @@ -464,6 +464,15 @@ appsFlyer.setCustomerUserId = (userId, successC) => { } }; +/** + * Get the customer user ID that was previously set with setCustomerUserId. + * + * @callback callback function that returns (error, userId) + */ +appsFlyer.getCustomerUserId = (callback) => { + return RNAppsFlyer.getCustomerUserId(callback); +}; + /** * Once this API is invoked, our SDK no longer communicates with our servers and stops functioning. * In some extreme cases you might want to shut down all SDK activity due to legal and privacy compliance. diff --git a/ios/RNAppsFlyer.m b/ios/RNAppsFlyer.m index 93ed7bf2..e6359719 100755 --- a/ios/RNAppsFlyer.m +++ b/ios/RNAppsFlyer.m @@ -241,6 +241,11 @@ -(NSError *) callSdkInternal:(NSDictionary*)initSdkOptions { callback(@[[NSNull null], version]); } +RCT_EXPORT_METHOD(getCustomerUserId: (RCTResponseSenderBlock)callback) { + NSString *userId = [[AppsFlyerLib shared] customerUserID]; + callback(@[[NSNull null], userId ?: @""]); +} + RCT_EXPORT_METHOD(setCustomerUserId: (NSString *)userId callback:(RCTResponseSenderBlock)callback) { [[AppsFlyerLib shared] setCustomerUserID:userId]; callback(@[AF_SUCCESS]); From 36ce4036b5e74a62111fd10b1c60da3d6dc4a644 Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 15:13:47 +0300 Subject: [PATCH 20/21] Remove getCustomerUserId API from React Native plugin --- .af-e2e/test-plan.json | 9 +-------- .../com/appsflyer/reactnative/RNAppsFlyerModule.java | 7 ------- example/src/App.tsx | 4 ---- index.d.ts | 1 - index.js | 9 --------- ios/RNAppsFlyer.m | 5 ----- 6 files changed, 1 insertion(+), 34 deletions(-) diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json index e22ce383..bea3f9f7 100644 --- a/.af-e2e/test-plan.json +++ b/.af-e2e/test-plan.json @@ -278,7 +278,7 @@ "id": "phase_5", "name": "Identity APIs round-trip", "scenario_ref": "E2E-005", - "description": "Fresh install. Verify setCustomerUserId, getCustomerUserId readback, setCurrencyCode, setAdditionalData propagate correctly. Identity-check event receives HTTP 200. is_first_launch=true still fires.", + "description": "Fresh install. Verify setCustomerUserId, setCurrencyCode, setAdditionalData propagate correctly. Identity-check event receives HTTP 200. is_first_launch=true still fires.", "requires_fresh_install": true, "wait_after_launch_sec": 420, "checks": [ @@ -289,13 +289,6 @@ "pattern": "[AF_QA][setCustomerUserId] result:", "fail_action": "fail" }, - { - "id": "customer_user_id_readback", - "description": "getCustomerUserId returns the set value", - "type": "log_contains", - "pattern": "[AF_QA][getCustomerUserId] result:", - "fail_action": "fail" - }, { "id": "currency_code", "description": "setCurrencyCode readback present", diff --git a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java index ea69fd9b..5b0df5ae 100755 --- a/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java +++ b/android/src/main/java/com/appsflyer/reactnative/RNAppsFlyerModule.java @@ -488,13 +488,6 @@ public void updateServerUninstallToken(final String token, Callback callback) { guardedCallback.invoke(SUCCESS); } - @ReactMethod - public void getCustomerUserId(Callback callback) { - CallbackGuard guardedCallback = new CallbackGuard(callback); - String userId = AppsFlyerLib.getInstance().getCustomerUserId(); - guardedCallback.invoke(null, userId != null ? userId : ""); - } - @ReactMethod public void setCustomerUserId(final String userId, Callback callback) { CallbackGuard guardedCallback = new CallbackGuard(callback); diff --git a/example/src/App.tsx b/example/src/App.tsx index 4771c22d..0b2de821 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -89,10 +89,6 @@ function runAutoFlow() { afLog('getSDKVersion', `result: ${version || err}`); }); - appsFlyer.getCustomerUserId((err, userId) => { - afLog('getCustomerUserId', `result: ${userId || err}`); - }); - afLifecycleLog('--- Post-start auto APIs complete ---'); // 5. Fire standard events (Promise API — Android CallbackGuard WeakReference diff --git a/index.d.ts b/index.d.ts index ad8842fd..412ce598 100644 --- a/index.d.ts +++ b/index.d.ts @@ -312,7 +312,6 @@ declare module "react-native-appsflyer" { setAdditionalData(additionalData: object, successC?: SuccessCB): void; getAppsFlyerUID(callback: (error: Error, uid: string) => any): void; getSDKVersion(callback: (error: Error, version: string) => any): void; - getCustomerUserId(callback: (error: Error | null, userId: string | null) => void): void; setCustomerUserId(userId: string, successC?: SuccessCB): void; stop(isStopped: boolean, successC?: SuccessCB): void; setAppInviteOneLinkID(oneLinkID: string, successC?: SuccessCB): void; diff --git a/index.js b/index.js index 8be9ee52..ba179c06 100755 --- a/index.js +++ b/index.js @@ -464,15 +464,6 @@ appsFlyer.setCustomerUserId = (userId, successC) => { } }; -/** - * Get the customer user ID that was previously set with setCustomerUserId. - * - * @callback callback function that returns (error, userId) - */ -appsFlyer.getCustomerUserId = (callback) => { - return RNAppsFlyer.getCustomerUserId(callback); -}; - /** * Once this API is invoked, our SDK no longer communicates with our servers and stops functioning. * In some extreme cases you might want to shut down all SDK activity due to legal and privacy compliance. diff --git a/ios/RNAppsFlyer.m b/ios/RNAppsFlyer.m index e6359719..93ed7bf2 100755 --- a/ios/RNAppsFlyer.m +++ b/ios/RNAppsFlyer.m @@ -241,11 +241,6 @@ -(NSError *) callSdkInternal:(NSDictionary*)initSdkOptions { callback(@[[NSNull null], version]); } -RCT_EXPORT_METHOD(getCustomerUserId: (RCTResponseSenderBlock)callback) { - NSString *userId = [[AppsFlyerLib shared] customerUserID]; - callback(@[[NSNull null], userId ?: @""]); -} - RCT_EXPORT_METHOD(setCustomerUserId: (NSString *)userId callback:(RCTResponseSenderBlock)callback) { [[AppsFlyerLib shared] setCustomerUserID:userId]; callback(@[AF_SUCCESS]); From c9836c10251c67abde284fdbbf58edd528ea796e Mon Sep 17 00:00:00 2001 From: AmitLY21 Date: Wed, 13 May 2026 15:29:41 +0300 Subject: [PATCH 21/21] ci: Ensure accurate fresh-install attribution for scenario testing Resets device identifiers (Android ID) or privacy settings (iOS simulator) when a fresh install is required. This ensures the AppsFlyer server treats the device as truly new, allowing for accurate `is_first_launch` reporting and reliable attribution testing. --- scripts/af-scenario-runner.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh index 1437c2ab..f6b053c6 100755 --- a/scripts/af-scenario-runner.sh +++ b/scripts/af-scenario-runner.sh @@ -693,6 +693,17 @@ run_phase() { # Fresh install if required if [[ "$requires_fresh" == "true" ]]; then platform_uninstall + # Reset device identity so the AppsFlyer server treats this as a brand-new + # device and returns is_first_launch=true. + if [[ "$PLATFORM" == "android" ]]; then + local new_id + new_id=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | head -c 16 || date +%s%N | head -c 16) + adb shell settings put secure android_id "$new_id" 2>/dev/null || true + log_info "Reset android_id to $new_id for fresh-install attribution" + else + xcrun simctl privacy "$IOS_UDID" reset all 2>/dev/null || true + log_info "Reset simulator privacy settings for fresh-install attribution" + fi sleep 1 if ! platform_install; then log_fail "Installation failed — aborting phase"