diff --git a/.af-e2e/test-plan.json b/.af-e2e/test-plan.json new file mode 100644 index 00000000..bea3f9f7 --- /dev/null +++ b/.af-e2e/test-plan.json @@ -0,0 +1,383 @@ +{ + "_meta": { + "plan_id": "reactnative-e2e", + "plugin": "reactnative", + "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", + "tooling_contract_ref": "E2E-001, E2E-002, E2E-003, E2E-004, E2E-005, E2E-006" + }, + + "config": { + "android": { + "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" + }, + "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 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 was called", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "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_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": "get_sdk_version", + "description": "getSDKVersion returns a value", + "type": "log_contains", + "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": "http_200_count", + "description": "At least 3 HTTP 200 responses from AppsFlyer servers", + "type": "count_matches", + "pattern": "response code:200 OK|response_status=200", + "minimum": 3, + "fail_action": "fail" + }, + { + "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", "FATAL", "[AF_QA][startSDK] error:", "response code:4", "response code:5"], + "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 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 launch {{UDID}} com.apple.mobilesafari", "sleep 2"] + }, + "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": "deeplink_status_found", + "description": "onDeepLinking fires with Status.FOUND", + "type": "log_contains", + "pattern": "status=FOUND", + "fail_action": "fail" + }, + { + "id": "deeplink_value_bg", + "description": "deepLinkValue matches qa_deeplink_bg", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_bg", + "fail_action": "fail" + }, + { + "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", "FATAL"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_3", + "name": "Foreground deep link", + "scenario_ref": "E2E-003", + "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": ["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}}\"", + "ios": "xcrun simctl openurl {{UDID}} \"{{DEEP_LINK_URL}}\"" + }, + "checks": [ + { + "id": "sdk_started", + "description": "startSDK was called on fresh install", + "type": "log_contains", + "pattern": "[AF_QA][startSDK] result:", + "fail_action": "abort" + }, + { + "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": "deeplink_status_found", + "description": "onDeepLinking fires with Status.FOUND after foreground deep link", + "type": "log_contains", + "pattern": "status=FOUND", + "fail_action": "fail" + }, + { + "id": "deeplink_value_fg", + "description": "deepLinkValue matches qa_deeplink_fg", + "type": "log_contains", + "pattern": "deepLinkValue=qa_deeplink_fg", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions", + "type": "absent", + "patterns": ["Fatal Exception", "FATAL"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_4", + "name": "Custom in-app event with parameters", + "scenario_ref": "E2E-004", + "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": "custom_event_logged", + "description": "af_qa_custom_purchase logEvent fires", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_qa_custom_purchase)]", + "fail_action": "fail" + }, + { + "id": "params_complete", + "description": "All 5 parameter keys present in the custom event log line", + "type": "log_contains", + "pattern": "[AF_QA][logEvent] name=af_qa_custom_purchase params=", + "fail_action": "fail" + }, + { + "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": "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_errors", + "description": "No fatal exceptions or process crashes", + "type": "absent", + "patterns": ["Fatal Exception", "FATAL"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_5", + "name": "Identity APIs round-trip", + "scenario_ref": "E2E-005", + "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": [ + { + "id": "customer_user_id_set", + "description": "setCustomerUserId readback present", + "type": "log_contains", + "pattern": "[AF_QA][setCustomerUserId] result:", + "fail_action": "fail" + }, + { + "id": "currency_code", + "description": "setCurrencyCode readback present", + "type": "log_contains", + "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_errors", + "description": "No fatal exceptions or process crashes", + "type": "absent", + "patterns": ["Fatal Exception", "FATAL"], + "fail_action": "fail" + } + ] + }, + + { + "id": "phase_6", + "name": "Consent / SDK stop toggle", + "scenario_ref": "E2E-006", + "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": "stop_true", + "description": "stop(true) readback present", + "type": "log_contains", + "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": "stop_false", + "description": "stop(false) readback present", + "type": "log_contains", + "pattern": "[AF_QA][stop] result: false", + "fail_action": "fail" + }, + { + "id": "resumed_event_http_200", + "description": "Resumed event fires and receives HTTP 200", + "type": "log_contains", + "pattern": "[AF_QA][logEvent(af_qa_resumed)] result:", + "fail_action": "fail" + }, + { + "id": "no_fatal_errors", + "description": "No fatal exceptions throughout stop/resume cycle", + "type": "absent", + "patterns": ["Fatal Exception", "FATAL"], + "fail_action": "fail" + } + ] + } + ], + + "report": { + "output_dir": ".af-e2e/reports", + "format": "json" + } +} 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/.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..787ed0b0 --- /dev/null +++ b/.claude/rules/known-issues-kb.md @@ -0,0 +1,147 @@ +# 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`. + +### 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 +**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/.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/**', diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml new file mode 100644 index 00000000..5e8a7d19 --- /dev/null +++ b/.github/workflows/android-e2e.yml @@ -0,0 +1,116 @@ +# ============================================================================= +# 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' + + - name: Install example app dependencies + working-directory: example + run: npm install + + - name: Write example app .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + echo "$ENV_FILE" | base64 -d > example/.env + echo "::group::.env keys (values redacted)" + sed 's/=.*/=***/' example/.env + echo "::endgroup::" + + - 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 ../.. + bash ./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..c91d0733 --- /dev/null +++ b/.github/workflows/ios-e2e.yml @@ -0,0 +1,177 @@ +# ============================================================================= +# 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' + + - 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', 'react-native-appsflyer.podspec') }} + restore-keys: pods- + + - name: Install example app dependencies + working-directory: example + run: npm install + + - name: Install CocoaPods + working-directory: example/ios + run: pod install + + - name: Write example app .env + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + echo "$ENV_FILE" | base64 -d > example/.env + echo "::group::.env keys (values redacted)" + sed 's/=.*/=***/' example/.env + echo "::endgroup::" + + - name: Cache iOS build output + uses: actions/cache@v5 + with: + path: example/ios/build + key: ios-build-${{ hashFiles('ios/**', 'index.js', 'example/ios/**', 'example/.env') }} + restore-keys: ios-build- + + - name: Build iOS simulator app (debug) + working-directory: example/ios + env: + FORCE_BUNDLING: "1" + run: | + xcodebuild build \ + -workspace example.xcworkspace \ + -scheme example \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_UDID" \ + -derivedDataPath build \ + | 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 + + - 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..7ef9ed9a --- /dev/null +++ b/.github/workflows/lint-test-build.yml @@ -0,0 +1,125 @@ +# ============================================================================= +# 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.run_id }}-${{ 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' + - run: npm install + - 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' + - uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - name: Install example app dependencies + working-directory: example + run: npm install + - 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' + - name: Install example app dependencies + working-directory: example + run: npm install + - 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 + + 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..124d4c26 --- /dev/null +++ b/.github/workflows/production-release.yml @@ -0,0 +1,506 @@ +# ============================================================================= +# 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: needs.validate-release.result == 'success' && needs.validate-release.outputs.is_valid == 'true' + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Node.js + 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: + VERSION: ${{ needs.validate-release.outputs.version }} + run: | + echo "Publishing version $VERSION to npm..." + npm publish + 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: needs.publish-to-npm.result == 'success' && 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 "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 + 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_HOOK }} + + - 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_HOOK }} + + # =========================================================================== + # 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..315120a2 --- /dev/null +++ b/.github/workflows/promote-release.yml @@ -0,0 +1,243 @@ +# ============================================================================= +# 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 --allow-same-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 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" + 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_HOOK }} + continue-on-error: true diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml new file mode 100644 index 00000000..1b37087c --- /dev/null +++ b/.github/workflows/rc-release.yml @@ -0,0 +1,855 @@ +# ============================================================================= +# 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' + + - 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 + if [[ -n "${PC_VER_INPUT:-}" ]]; then + PC_VER="$PC_VER_INPUT" + 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 + 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 -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 + 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" || "$E2E_IOS_RESULT" == "skipped" ]]; then + ios_ok=true + fi + if [[ "$E2E_ANDROID_RESULT" == "success" || "$E2E_ANDROID_RESULT" == "skipped" ]]; 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: always() && needs.pre-publish-gate.result == 'success' && 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: 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 + with: + fetch-depth: 0 + ref: ${{ needs.prepare-branch.outputs.release_branch }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install + + - 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' }} + run: npm publish --tag rc + + # =========================================================================== + # 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 + ok() { [[ "$1" == "success" || "$1" == "skipped" ]]; } + + if [[ "$VALIDATE_RESULT" == "success" \ + && "$PREPARE_RESULT" == "success" \ + && "$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 + 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 ! ok "$JIRA_RESULT"; then + echo "failed_stage=validate-jira" >> "$GITHUB_OUTPUT" + elif ! ok "$PUBLISH_RESULT"; 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' + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "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' + uses: slackapi/slack-github-action@v1 + with: + payload: | + { + "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 }} + + # =========================================================================== + # 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 "=========================================" + + 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" + 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..c0bd9806 --- /dev/null +++ b/.github/workflows/rc-smoke.yml @@ -0,0 +1,499 @@ +# ============================================================================= +# 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: echo "$ENV_FILE" | base64 -d > 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: Build iOS (debug) + working-directory: example_rc_smoke/ios + env: + FORCE_BUNDLING: "1" + run: | + xcodebuild build \ + -workspace example.xcworkspace \ + -scheme example \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_UDID" \ + -derivedDataPath build \ + | xcpretty + + - 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: echo "$ENV_FILE" | base64 -d > 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/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 + + - 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_HOOK }} + + # =========================================================================== + # 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 diff --git a/.gitignore b/.gitignore index 2d311999..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 @@ -70,4 +68,29 @@ 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/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 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 | 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/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 | 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/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..9c6dc253 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,124 @@ +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"] + if (System.getenv("CI") != null) { + debuggableVariants = [] + } + + /* 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 00000000..364e105e Binary files /dev/null and b/example/android/app/debug.keystore differ diff --git a/example/android/app/proguard-rules.pro b/example/android/app/proguard-rules.pro new file mode 100644 index 00000000..11b02572 --- /dev/null +++ b/example/android/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..52c5abee --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + 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/MainActivity.kt b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt new file mode 100644 index 00000000..2c5e47f2 --- /dev/null +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainActivity.kt @@ -0,0 +1,20 @@ +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 +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + override fun getMainComponentName(): String = "example" + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } +} 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..32d63622 --- /dev/null +++ b/example/android/app/src/main/java/com/appsflyer/qa/reactnative/MainApplication.kt @@ -0,0 +1,26 @@ +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 { + add(AfQaNativeLoggerPackage()) + }, + ) + } + + 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 00000000..a2f59082 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..1b523998 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..ff10afd6 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..115a4c76 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..dcd3cd80 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 00000000..459ca609 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ 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 00000000..8ca12fe0 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ 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 00000000..8e19b410 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b824ebdd Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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 00000000..4c19a13c Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ 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 00000000..61285a65 Binary files /dev/null and b/example/android/gradle/wrapper/gradle-wrapper.jar differ 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/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 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..e8b670d7 --- /dev/null +++ b/example/src/AfQaLogger.ts @@ -0,0 +1,25 @@ +import {NativeModules, Platform} from 'react-native'; + +const LOG_TAG = '[AF_QA]'; + +// 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}`); +} + +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..51fbece7 --- /dev/null +++ b/example/src/App.tsx @@ -0,0 +1,207 @@ +// @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}`); + }); + + 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 + 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 (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', + }) + .then((result: any) => afLog('logEvent(af_purchase)', `result: ${result}`)) + .catch((error: any) => afLog('logEvent(af_purchase)', `error: ${error}`)); + + appsFlyer + .logEvent('af_content_view', { + af_content_id: 'qa-content-001', + af_content_type: 'test', + }) + .then((result: any) => afLog('logEvent(af_content_view)', `result: ${result}`)) + .catch((error: any) => afLog('logEvent(af_content_view)', `error: ${error}`)); + + // 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]'); + + const consent = new AppsFlyerConsent(true, true, true, true); + appsFlyer.setConsentData(consent); + afLog('setConsentData', 'result: GDPR consent set'); + + // 9. Stop/resume cycle (E2E-006) + // Delay so the SDK has time to receive onInstallConversionData from the + // server before we stop it. Without this, stop(true) fires ~9ms after + // startSdk() and kills the in-flight conversion data request. + setTimeout(() => { + 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); + }); + }, 10000); +} + +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"] +} 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]); diff --git a/scripts/af-scenario-runner.sh b/scripts/af-scenario-runner.sh new file mode 100755 index 00000000..f6b053c6 --- /dev/null +++ b/scripts/af-scenario-runner.sh @@ -0,0 +1,1012 @@ + +#!/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 + adb logcat -d 2>/dev/null | grep -F "$LOG_TAG" 2>/dev/null || true + 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) + 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] +# 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" | 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" | tr -d '\000-\037' | 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 | 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 + 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 + # 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" + 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