diff --git a/.claude/WORKFLOW.md b/.claude/WORKFLOW.md new file mode 100644 index 00000000..1c5fa647 --- /dev/null +++ b/.claude/WORKFLOW.md @@ -0,0 +1,163 @@ +# AppsFlyer Flutter Plugin β€” AI Skill Workflow + +> Last updated: auto-generated + +Describes how the Claude Code skills communicate and which `docs/` directories each one reads or writes. + +--- + +## Skill Communication & Docs Access + +```mermaid +flowchart TD + User(["πŸ‘€ User"]) + + User -->|"/af-ship"| Orch + User -->|"/af-ship-from-prd"| Orch + User -->|"/af-ship-from-tech-design"| Orch + User -->|"maintenance task"| Dave + + Orch["🚦 af-ship-orch
Entry router
Creates tasks Β· fetches docs"] + Alice["πŸ‘©β€πŸ’Ό Alice PM
alice-pm
Owns: WHAT"] + Bob["πŸ‘¨β€πŸ”¬ Bob
bob-flutter-researcher
Owns: R-NNN docs"] + Erin["πŸ‘©β€πŸ’» Erin
erin-flutter-analyst
Owns: P-NNN docs"] + Dave["πŸ‘¨β€πŸ’» Dave
dave-flutter-engineer
Owns: HOW + F-NNN docs"] + + Orch -->|"delegates to"| Alice + Alice -->|"platform / API unclear"| Bob + Alice -->|"payloads / contracts affected"| Erin + Bob -->|"findings"| Alice + Erin -->|"field map"| Alice + Alice -->|"after Bob/Erin satisfied"| Dave + Dave -->|"code / tech design"| Alice + Alice -->|"unresolved after 2x"| User + + subgraph docs ["πŸ“ docs/"] + PRDs["prds/
PRDs (staging)"] + TechDesigns["tech-designs/
Tech designs (staging)"] + Features["features/
F-NNN Β· feature catalog"] + IssueCases["issue-cases/
IC-NNN Β· scar book
GUARDRAILS.md"] + Researches["researches/
R-NNN Β· research log"] + Payloads["payloads/
P-NNN Β· field maps"] + end + + Orch -.->|"saves fetched PRD"| PRDs + Orch -.->|"saves fetched tech design"| TechDesigns + Alice -.->|writes| PRDs + Alice -.->|reads| Features + Alice -.->|reads| IssueCases + Dave -.->|writes| TechDesigns + Dave -.->|reads + writes| Features + Dave -.->|reads| IssueCases + Bob -.->|writes| Researches + Bob -.->|reads| Features + Bob -.->|reads| IssueCases + Erin -.->|writes| Payloads + Erin -.->|reads| Features + Erin -.->|reads| IssueCases +``` + +**Solid arrows** = skill invocation (who calls whom). +**Dotted arrows** = docs read/write access. + +--- + +## Docs Layer β€” Who Owns What + +| Directory | Nickname | Owner | Consumers | +|-----------|----------|-------|-----------| +| `internal-docs/prds/` | PRDs (staging) | Alice (writes); af-ship-orch (saves external) | User review; may move to Notion | +| `internal-docs/tech-designs/` | Tech designs (staging) | Dave (writes); af-ship-orch (saves external) | User review; may move to Notion | +| `internal-docs/features/` | Feature catalog | Dave (writes F-NNN) | Alice, Bob, Erin (read) | +| `internal-docs/issue-cases/` | Scar book | Human / eng team | Alice, Dave, Bob, Erin (read) | +| `internal-docs/researches/` | Research log | Bob (writes R-NNN) | Alice (via challenge loop) | +| `docs/payloads/` | Payload map | Erin (writes P-NNN, FIELD_MAP) | Alice, Dave (via challenge loop) | + +--- + +## Invocation Rules + +| Entry point | When | +|-------------|------| +| `/af-ship ` | Starting a new feature from scratch | +| `/af-ship --prd ` | Starting from an existing PRD (Notion URL or local .md) | +| `/af-ship --tech-design ` | Starting from an existing tech design (Notion URL or local .md) | +| `/af-ship-from-prd ` | Same as `--prd` flag; dedicated command alternative | +| `/af-ship-from-tech-design ` | Same as `--tech-design` flag; dedicated command alternative | +| Dave (direct) | Maintenance only: logs, renames, dead-code removal, comment cleanup, test additions, minor refactors with no public API change | +| Bob (direct) | Ad-hoc platform/API research not tied to a feature | +| Erin (direct) | Ad-hoc payload or schema analysis not tied to a feature | +| Bob | Invoked by Alice when platform API / version / external behavior is unclear | +| Erin | Invoked by Alice when payloads, request fields, or server-visible schema is affected | + +If unsure whether a task is maintenance or a feature β†’ use `/af-ship`. + +--- + +## Loop Mechanics + +**New feature from scratch:** +``` +/af-ship + β†’ af-ship-orch creates task wizard β†’ calls alice-pm + β†’ Alice writes PRD β†’ saves to internal-docs/prds/.md β†’ asks user to review + β†’ User approves PRD + β†’ Alice invokes Bob and/or Erin if needed + β†’ Bob/Erin produce findings β†’ Alice challenges (max 2 iterations) + β†’ Alice updates PRD if scope changed β†’ Alice invokes Dave + β†’ [Phase 1 / 2 / 3 below] +``` + +**From existing PRD:** +``` +/af-ship-from-prd (or /af-ship --prd ) + β†’ af-ship-orch fetches / reads PRD β†’ saves to internal-docs/prds/.md β†’ calls alice-pm + β†’ Alice challenges PRD for completeness β†’ resolves gaps with user + β†’ Alice delegates to Bob/Erin/Dave (no second review pause) + β†’ [Phase 1 / 2 / 3 below] +``` + +**From existing tech design:** +``` +/af-ship-from-tech-design (or /af-ship --tech-design ) + β†’ af-ship-orch fetches / reads tech design β†’ saves to internal-docs/tech-designs/.md β†’ calls alice-pm + β†’ Alice runs full challenge agenda β†’ Dave addresses issues (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." + β†’ [Phase 2 / 3 below β€” Phase 1 skipped, PRD gate bypassed] +``` + +**Phase 1 β€” Tech design** +``` + β†’ Dave writes tech design β†’ saves to internal-docs/tech-designs/.md + β†’ Alice challenges tech design (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." + β†’ Dave asks user to review tech design + β†’ User approves tech design +``` + +**Phase 2 β€” Implementation** +``` + β†’ Dave implements + writes unit tests + β†’ Alice challenges implementation (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." +``` + +**Phase 3 β€” Feature doc** +``` + β†’ Dave runs impact scan β†’ updates any affected existing F-NNN docs + β†’ Dave writes new F-NNN feature doc β†’ saves to internal-docs/features/ + β†’ Alice challenges feature doc (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." +``` + +Escalation: if any item is unresolved after 2 full challenge loops β†’ Alice escalates to User. + +--- + +## Authority Map + +| Question | Owner | +|----------|-------| +| WHY β€” strategy, business goal | User (escalated by Alice) | +| WHAT β€” requirements, scope, acceptance criteria | Alice | +| HOW β€” architecture, implementation, tech tradeoffs | Dave | diff --git a/.claude/commands/af-quiz-me.md b/.claude/commands/af-quiz-me.md new file mode 100644 index 00000000..e58f4bcb --- /dev/null +++ b/.claude/commands/af-quiz-me.md @@ -0,0 +1,115 @@ +Generate an interactive HTML quiz from a tech design document. + +## Step 1 β€” Resolve the document + +**If $ARGUMENTS is empty:** +List all `.md` files in `internal-docs/tech-designs/`. +- If files exist, list them and ask: + "Which tech design should I quiz you on? (Reply with the number or filename) + Or reply **project** to generate a quiz covering the whole project from the feature catalog." + Wait for the user's selection before continuing. +- If the folder does not exist or is empty, ask: + "No tech designs found in `internal-docs/tech-designs/`. What would you like to do? + 1. Provide a path or Notion URL (reply with the path/URL) + 2. Generate a project quiz from the feature catalog (reply **project**)" + Wait for the user's reply before continuing. + +**If the user replies `project` (or $ARGUMENTS is `project`):** +Check whether `internal-docs/features/INDEX.md` exists. +- If it does not exist, stop and say: + "No feature catalog found. Run `/af-generate-feature-catalog` first to build `internal-docs/features/`, then try again." +- If it exists, read `internal-docs/features/INDEX.md` to get the full list of features, then read each individual `internal-docs/features/F-*.md` file. + Set `` to `project` and `` to the project name derived from `INDEX.md` (e.g. `MyProject β€” Project Quiz`). + Proceed to Step 3 in **project mode** (random 10 questions across all features). + +**If $ARGUMENTS is provided (and not `project`):** +- Starts with `http` β†’ fetch using the `notion-fetch` MCP tool. +- Otherwise β†’ read the file at the given path directly. + +## Step 2 β€” Derive the feature slug and title + +From the document title or filename, derive: +- `<slug>` β€” kebab-case short name (e.g. `sharedprefs-encryption`) +- `<title>` β€” human-readable title for display (e.g. `SharedPreferences Encryption`) + +## Step 3 β€” Generate 10 quiz questions + +Read the resolved document(s) in full. Generate exactly 10 questions as a JSON array +using this exact structure: + +```json +[ + { + "q": "Question text", + "opts": ["Option A", "Option B", "Option C", "Option D"], + "ans": 2, + "exp": "One-sentence explanation of why the correct answer is correct." + } +] +``` + +- `ans` is the zero-based index of the correct option (0–3). +- Every question must have exactly 4 options. + +### Answer position distribution + +Before writing the JSON, randomly assign a correct answer position (0–3) for +each of the 10 questions. No single index may appear more than 3 times across +the set, ensuring the correct answers are spread across A, B, C, and D. + +For each question, place the correct option at its assigned position and fill +the remaining slots with distractors. Set `ans` to match. + +Never write all questions with the correct answer at index 0 β€” this is the +natural default when drafting distractors after the correct answer, and it +must be explicitly overridden. + +### What to quiz on + +**Tech design mode** β€” focus on: +- Business problem and motivation β€” why this feature exists +- Customer or user impact β€” who benefits and how +- Product goals and success criteria β€” what done looks like +- Scope and non-goals β€” what is in vs out +- Key decisions and tradeoffs β€” why the chosen approach over alternatives +- Risks and mitigations β€” what could go wrong and how it is handled +- Integration and rollout β€” how this lands in the product + +**Project mode** β€” pick 10 questions randomly across all features, covering: +- What a feature does and why it exists (Business Purpose) +- What the product loses if a feature is removed +- How features depend on or interact with each other +- What triggers a feature and what it produces +- Known limitations or platform gaps +- Ensure broad spread: do not pick more than 2 questions from the same feature + +### What NOT to quiz on + +- Exact field names, formula strings, or API parameter names +- Specific numeric constants or thresholds (unless they represent a product decision) +- Low-level implementation details only the author would know +- Trivia answerable by ctrl+F rather than understanding + +## Step 4 β€” Build the output file + +Read the template from `templates/af-tech-quiz-template.html`. + +Replace both placeholders: +- `{{QUIZ_TITLE}}` β†’ the human-readable title from Step 2 (appears twice: in <title> and in JS) +- `{{QUESTIONS_JSON}}` β†’ the full JSON array from Step 3 (no trailing semicolon β€” the template already has one) + +Create the output directory if it does not exist: +```bash +mkdir -p output.af-tech-quiz +``` + +Write the result to `output.af-tech-quiz/af-tech-quiz-<slug>.html`. + +## Step 5 β€” Open in browser + +Run: +```bash +open output.af-tech-quiz/af-tech-quiz-<slug>.html +``` + +Then tell the user: "Quiz saved to `output.af-tech-quiz/af-tech-quiz-<slug>.html` and opened in your browser." diff --git a/.claude/commands/af-ship-from-prd.md b/.claude/commands/af-ship-from-prd.md new file mode 100644 index 00000000..7224dbd0 --- /dev/null +++ b/.claude/commands/af-ship-from-prd.md @@ -0,0 +1,12 @@ +If $ARGUMENTS is empty, stop and ask: +"Please provide a Notion URL or a path to a local .md file for the PRD. +Example: `/af-ship-from-prd https://notion.so/team/my-prd` +Example: `/af-ship-from-prd internal-docs/prds/my-feature.md`" +Do not proceed until the user provides a URL or path. + +Start the feature delivery workflow using an existing PRD. +The PRD source is: $ARGUMENTS + +Invoke the `af-ship-orch` skill now in PRD-Given mode. +It will fetch and save the PRD, then call Alice to challenge it for completeness, +resolve gaps with you, and delegate to Bob/Erin/Dave. diff --git a/.claude/commands/af-ship-from-tech-design.md b/.claude/commands/af-ship-from-tech-design.md new file mode 100644 index 00000000..4516843b --- /dev/null +++ b/.claude/commands/af-ship-from-tech-design.md @@ -0,0 +1,12 @@ +If $ARGUMENTS is empty, stop and ask: +"Please provide a Notion URL or a path to a local .md file for the tech design. +Example: `/af-ship-from-tech-design https://notion.so/team/my-design` +Example: `/af-ship-from-tech-design internal-docs/tech-designs/my-feature.md`" +Do not proceed until the user provides a URL or path. + +Start the delivery workflow using an existing tech design. +The tech design source is: $ARGUMENTS + +Invoke the `af-ship-orch` skill now in Tech-Design-Given mode. +It will fetch and save the tech design, then call Alice to run her full challenge agenda, +work with Dave to resolve any issues, then proceed to implementation after your approval. diff --git a/.claude/commands/af-ship.md b/.claude/commands/af-ship.md new file mode 100644 index 00000000..d75be3be --- /dev/null +++ b/.claude/commands/af-ship.md @@ -0,0 +1,40 @@ +Check $ARGUMENTS for flags before doing anything else: + +**If $ARGUMENTS starts with `--prd `:** +Extract the URL or path that follows `--prd `. +If nothing follows `--prd`, stop and ask: +"Please provide a Notion URL or local .md path after --prd +(e.g. `/af-ship --prd https://notion.so/team/my-prd`)." +Do not proceed until a URL or path is provided. +Otherwise: invoke the `af-ship-orch` skill in PRD-Given mode. +The PRD source is the value extracted from $ARGUMENTS after `--prd `. + +**If $ARGUMENTS starts with `--tech-design `:** +Extract the URL or path that follows `--tech-design `. +If nothing follows `--tech-design`, stop and ask: +"Please provide a Notion URL or local .md path after --tech-design +(e.g. `/af-ship --tech-design internal-docs/tech-designs/my-feature.md`)." +Do not proceed until a URL or path is provided. +Otherwise: invoke the `af-ship-orch` skill in Tech-Design-Given mode. +The tech design source is the value extracted from $ARGUMENTS after `--tech-design `. + +**If $ARGUMENTS starts with `--` (unrecognized flag):** +Stop and ask: +"Unrecognized flag. Supported flags are: +- `--prd <url-or-path>` β€” start from an existing PRD +- `--tech-design <url-or-path>` β€” start from an existing tech design +Or provide a feature description directly (e.g. `/af-ship add dark mode`)." +Do not proceed. + +**If $ARGUMENTS contains no flags (default β€” new feature from scratch):** +If $ARGUMENTS is empty or contains only one word, stop and ask: +"What feature would you like to implement? Please give a short description +(e.g. `/af-ship add dark mode to settings screen`)." +Do not proceed until the user provides a description. +Otherwise, start the full feature delivery workflow for the following feature: + +$ARGUMENTS + +Invoke the `af-ship-orch` skill now to begin. It will set up the workflow tasks, +then hand off to Alice to write a PRD, coordinate research and engineering +through tech design, implementation, and feature documentation. diff --git a/.claude/prompts/generate-feature-catalog.md b/.claude/prompts/generate-feature-catalog.md new file mode 100644 index 00000000..8b5fc51f --- /dev/null +++ b/.claude/prompts/generate-feature-catalog.md @@ -0,0 +1,325 @@ +# Prompt: Create Feature Catalog + +Use this prompt to generate a `internal-docs/features/` catalog for this project. +Values below are filled during workflow setup β€” edit them here if needed. + +--- + +## Inputs + +``` +PROJECT_CONTEXT: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel +LANGUAGES: Dart, Objective-C, Java, Kotlin +NOTION_DB_URL: +NOTION_KEYWORDS: +JIRA_PROJECT_KEY: DELIVERY +``` + +--- + +## Prompt + +```` +Create a feature catalog for this project under `internal-docs/features/`. + +Project context: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel +Primary language(s): Dart, Objective-C, Java, Kotlin + +--- + +## Step 0 β€” Create workflow tasks + +Call `TaskCreate` for each step in order to give a live progress view: + +| Subject | activeForm | +|---------|------------| +| Check docs & external sources | Checking availability | +| Discover features from code | Scanning codebase | +| Verify & prune feature list | Verifying features | +| User reviews feature list | Waiting for approval | +| Propose taxonomy | Proposing categories | +| User reviews taxonomy | Waiting for approval | +| Write feature catalog | Writing feature docs | +| Dependency audit | Auditing dependencies | +| Notion enrichment | Enriching from Notion | +| Jira enrichment | Enriching from Jira | + +Immediately mark "Check docs & external sources" as `in_progress`. + +--- + +## Phase 0 β€” Check web docs and Notion availability + +### Part A β€” Web docs (optional β€” edit the list below before running) + +WEB_DOCS_URLS: + (none β€” add official documentation URLs here if available, one per line) + +If no URLs are listed above, say "No web docs URL provided β€” skipping Phase 0A" and proceed to Part B. + +If URLs are listed above: +1. Fetch the main page of each URL. +2. Discover the navigation structure (sitemap, sidebar links, category pages). +3. Build a list of relevant sub-pages whose titles match the project domain. Keep this list in memory β€” do NOT fetch sub-pages yet. +4. Say: "Web docs detected. Found N candidate pages. Context will be fetched per-feature during Phase 3 Business Purpose writing." + +Do not fetch sub-pages now. Proceed to Part B. + +### Part B β€” Check Notion availability + +If NOTION_DB_URL is provided, say: +"Notion URL detected. Business Purpose enrichment will happen in Phase 4, after the catalog is built. Proceeding to Phase 1." +Then proceed to Phase 1. Do not fetch Notion yet. + +If NOTION_DB_URL is blank, pause and say exactly: + +> **Action required β€” Notion enrichment** +> +> The **Business Purpose** section is the most valuable part of each feature doc β€” it answers "what does the product lose if this feature is deleted?" Code alone rarely answers that question; it lives in product specs, PRDs, and design documents. +> +> If your team stores specs or PRDs in Notion, providing a database URL now means every feature doc gets its Business Purpose enriched automatically in Phase 4. +> +> - **Do you have a Notion database with product specs or PRDs for this project?** +> - Reply with the Notion database URL to enable enrichment. You can also add keywords to filter pages (e.g. `launch, attribution, session`) β€” if you don't, **`Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel`** (the project name) will be used as the default filter. +> - Reply **skip** to proceed without Notion β€” Business Purpose sections will be derived from code only and marked `> TODO: enrich from product specs`. + +Wait for the user's reply before continuing. +- If they provide a URL: store it as NOTION_DB_URL. If they also provided keywords store them as NOTION_KEYWORDS; otherwise set NOTION_KEYWORDS to the project name from PROJECT_CONTEXT. Confirm "Notion enrichment enabled. Proceeding to Phase 1." and proceed. +- If they reply **skip**: say "Proceeding without Notion. Business Purpose sections will be marked TODO." and proceed to Phase 1. + +--- + +## Phase 1 β€” Discover features from code (no classification yet) + +Mark "Check docs & external sources" as completed. Mark "Discover features from code" as `in_progress`. + +Read the project's public interfaces, entry points, core implementation files, and any existing documentation under `docs/`. Scan every subdirectory. + +Use the language(s) listed in the inputs to determine where public interfaces live: + +| Language | Where to look | +|---|---| +| **Swift / Objective-C** | `.h` public headers, `public`/`open` Swift declarations, module maps | +| **Kotlin / Java** | `public` class/interface declarations, `@JvmStatic`, object companions | +| **Go** | Exported identifiers in `pkg/`, `cmd/` entry points, `internal/` | +| **Python** | `__init__.py` exports, `def`/`class` in `src/` or top-level packages | +| **TypeScript / JavaScript** | `index.ts/js`, `export` statements, React component files | +| **Bash / Shell** | Top-level scripts, `function` declarations, sourced library files | +| **Terraform** | `resource`, `module`, `data` blocks; `variables.tf`; `outputs.tf` | + +For each discrete capability, output one line: + F-NNN (provisional) | Feature Name | One-sentence purpose | Key file(s) + +Do NOT assign categories yet. Aim for comprehensive coverage β€” prefer over-listing and pruning to under-listing. + +--- + +## Phase 1.5 β€” Verify every feature has code in this project + +Mark "Discover features from code" as completed. Mark "Verify & prune feature list" as `in_progress`. + +For every feature: +- Confirm at least one file in this repository implements or exposes it. +- If no file can be found, mark it ❌ and explain why (server-side only, separate repo, third-party, documentation only, etc.). + +Remove all ❌ features. Present the pruned list with a short note on what was removed. Mark "Verify & prune feature list" as completed. Mark "User reviews feature list" as `in_progress`. Wait for confirmation before continuing to Phase 2. + +--- + +## Phase 2 β€” Propose a taxonomy + +Mark "User reviews feature list" as completed. Mark "Propose taxonomy" as `in_progress`. + +Propose 3–6 categories that fit this project's domain. Do not import categories from other projects. + +**Naming rule:** Category names must be valid mermaid identifiers β€” alphanumeric and underscores only. No hyphens. Use camelCase for multi-word names (e.g. `deepLinking`, not `deep-linking`). + +For each proposed category: +- Name it (camelCase if multi-word) +- One sentence: what kind of feature belongs here +- Which discovered features you would place in it + +Mark "Propose taxonomy" as completed. Mark "User reviews taxonomy" as `in_progress`. Present the proposed taxonomy and wait for approval before continuing to Phase 3. + +--- + +## Phase 3 β€” Create `internal-docs/features/` + +Mark "User reviews taxonomy" as completed. Mark "Write feature catalog" as `in_progress`. + +### `INDEX.md` +One table per category. Columns: `ID | Name | Status | Platform`. Assign final sequential IDs (F-001, F-002, …). Most foundational feature = F-001. + +### `TEMPLATE.md` + +~~~markdown +--- +id: F-NNN +name: Feature Name +type: [category] +platform: [platform] +status: active / planned / deprecated +last_verified: YYYY-MM-DD +depends_on: [] +--- + +## Business Purpose +Why this feature exists. What the user or product loses if it is removed. + +--- + +## Trigger +When this feature runs. What condition activates it. + +--- + +## Call Chain +\`\`\` +EntryPoint::method() + β†’ NextLayer::method() [file] + β†’ FinalLogic::method() [file] +\`\`\` + +--- + +## Files +| File | Role | +|------|------| + +--- + +## Input / Output +| | | +|--|--| +| **Input** | What comes in | +| **Output** | What goes out | + +--- + +## Tests +`path/to/test_file` β€” what the tests cover. + +--- + +## Known Limitations +- Limitation β€” why it exists, what the risk is + +--- + +## Dependencies +```mermaid +flowchart LR + FXXX["F-XXX Β· This Feature"]:::typeA -->|"relationship"| FYYY["F-YYY Β· Other Feature"]:::typeB + [classDef blocks β€” one per approved category] +``` +~~~ + +### Individual `F-NNN-[slug].md` for every feature + +Fill all sections from actual code. Business Purpose: derive from code what removing this feature breaks, then enrich from web docs if available. If a section does not apply, remove it. If you cannot fill a section, write `> TODO:` β€” do not fabricate. + +If Notion was skipped in Phase 0B, end every **Business Purpose** section with: +`> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically.` + +--- + +### `DIAGRAM.md` + +Write after all `F-NNN-*.md` files are complete. Aggregate the `depends_on` frontmatter and mermaid edges from every feature file into one document with three sections: + +**Section 1 β€” Runtime Flow** (`flowchart TD`) + +One subgraph per approved category. Include every feature that has at least one outbound or inbound cross-feature edge. Node format: `F001["F-001<br/>Feature Name"]:::category`. One `classDef` block per category (same colors used in individual files). Edges need no label here β€” topology is enough. + +**Section 2 β€” Initialization Flow** (`flowchart LR`) + +Flat diagram (no subgraphs). Include only features that configure, register, gate, or boot other features at startup time. Typically: the SDK init entry point, the service locator / DI container, any boot sequencer, remote-config / feature-flag loaders, and the first-party infrastructure they wire up. Exclude measurement, deep-link, and attribution nodes unless they are explicitly registered during init. + +**Section 3 β€” Dependency Table** (markdown table) + +Columns: `Feature | Depends On | Note`. One row per dependency edge. Note should be one sentence explaining _why_ the dependency exists (what the dependant feature gets from the dependency). Include every edge from both diagrams. Sort by Feature ID ascending. + +Title format: `# {{PROJECT_NAME}} β€” Feature Diagrams` + +--- + +## Phase 3.5 β€” Dependency audit (mandatory, no user input required) + +Mark "Write feature catalog" as completed. Mark "Dependency audit" as `in_progress`. + +Run immediately after all `F-NNN-*.md` files are written. + +**Step 1 β€” Find all isolated nodes:** features where `depends_on: []` or the mermaid block has only one node with no edges. + +**Step 2 β€” Verify each is genuinely standalone:** check route registration, bootstrap code, client constructors, and orchestration call chains for hidden shared dependencies (middleware, credential providers, utility helpers). + +**Step 3 β€” Fix and report:** + +| Feature | Was isolated | Hidden dependency found | Fixed | +|---------|-------------|------------------------|-------| + +Mark "Dependency audit" as completed. + +--- + +## Phase 4 β€” Notion enrich (skip if NOTION_DB_URL is blank) + +Mark "Notion enrichment" as `in_progress`. + +Run only after all `F-NNN-*.md` files have been created. + +1. Tell the user: "Phase 3 complete. Starting Notion enrichment β€” reply 'skip' to skip, or press Enter to continue." Wait for reply. +2. Fetch the database index at . +3. Filter pages whose title matches: +4. Before enriching, print a table of all meaningful Notion documents found: Title | Notion ID | Status | Likely enriches. +5. For each feature file: rewrite **only** the `## Business Purpose` section using the most recently edited relevant Notion page. Never paste verbatim. +6. Print Sources Used report: Title | Notion ID | Status | Used to enrich. + +Rules: last-edited date is the primary ranking signal. Notion content enriches Business Purpose only. Do not create new feature files from Notion content. + +--- + +## Phase 4B β€” Jira enrich + +Mark "Notion enrichment" as completed. Mark "Jira enrichment" as `in_progress`. + +Run after Phase 4 (or Phase 3 if Phase 4 was skipped). + +If JIRA_PROJECT_KEY is blank, default it to `DELIVERY`. + +Tell the user: "Starting Jira enrichment (project: DELIVERY) β€” reply 'skip' to skip." Wait for reply. If they skip, mark "Jira enrichment" as completed and end Phase 4B. + +1. Proceed with enrichment. +2. Extract the seed ticket from `git branch --show-current`. Walk up to Epic and Initiative. +3. Also run keyword search across project DELIVERY for each feature. +4. Before enriching, print Jira sources found: Key | Title | Type | Updated | Likely enriches. +5. For each feature file: append strategic "why" from Epic/Initiative to Business Purpose; add Known Limitations from Bug issues. +6. Print Jira Sources Used report. + +Rules: walk up (Story β†’ Epic β†’ Initiative), never down. Last-updated date is primary ranking signal. Jira enriches Business Purpose and Known Limitations only. + +Mark "Jira enrichment" as completed. + +--- + +## Mermaid diagram rules + +1. Always use `flowchart LR` for dependency diagrams. +2. Class names must be valid mermaid identifiers (camelCase, no hyphens). +3. Use ` Β· ` as separator in feature node labels: `F001["F-001 Β· SDK Initialization"]:::platform` +4. Sanitize special chars in labels: `[`, `]`, `{`, `}`, `<`, `>` β†’ use parentheses or plain text. +5. No UML class body blocks `{ }` inside flowchart. +6. Every arrow must carry a descriptive edge label: `-->|"registers task executor in"|` +7. Color cross-feature nodes by their own category. +8. One distinct fill color per category, always `color:#fff`. + +--- + +## Quality rules + +- **Business Purpose** answers: "what does the user or product lose if this is deleted?" +- **Call chains** trace from the public API entry to the leaf implementation. +- **Known Limitations** are honest: evasion vectors, missing coverage, platform gaps. +- **An isolated mermaid node is a red flag.** Confirm in code before leaving it isolated. +- **Dependency diagrams** show only feature-to-feature or feature-to-named-external-system edges. +```` diff --git a/.claude/prompts/generate-issue-cases.md b/.claude/prompts/generate-issue-cases.md new file mode 100644 index 00000000..2dadc807 --- /dev/null +++ b/.claude/prompts/generate-issue-cases.md @@ -0,0 +1,521 @@ +# Prompt: Generate Issue Cases from Git History + +Copy and paste the block below into the target Claude Code session. +Values below are filled during workflow setup β€” edit them here if needed. + +--- + +## Inputs + +``` +PROJECT_CONTEXT: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel +LANGUAGES: Dart, Objective-C, Java, Kotlin +``` + +--- + +## TASK + +Mine this repository's full git history across all branches and generate `internal-docs/issue-cases/` β€” an engineering issue case bank with a hot zones map and two-axis classification (Component Γ— Bug Class). + +Project context: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel +Primary language(s): Dart, Objective-C, Java, Kotlin + +Create `internal-docs/issue-cases/INDEX.md`, `internal-docs/issue-cases/TEMPLATE.md`, `internal-docs/issue-cases/GUARDRAILS.md`, and individual `IC-NNN-*.md` files. + +--- + +## Step 0 β€” Create workflow tasks + +Call `TaskCreate` for each step in order to give a live progress view: + +| Subject | activeForm | +|---------|------------| +| Spawn year agents | Spawning agents | +| Mine git history | Mining commits | +| Cross-check with Jira | Verifying Jira bugs | +| Align IC cases | Aligning cases | +| Build hot zones map | Mapping hot zones | +| Write individual IC cases | Writing cases | +| Generate GUARDRAILS.md | Writing guardrails | +| Write INDEX.md | Writing index | +| Dependency audit | Auditing dependencies | +| Update CLAUDE.md | Updating CLAUDE.md | +| Add pre-edit hook | Adding hook | +| Update persona skills | Updating skills | + +Immediately mark "Spawn year agents" as `in_progress`. + +--- + +## Step 0.5 β€” Detect repo years and spawn parallel mining agents + +Determine which calendar years to mine (last 9 years maximum): + +```bash +CURRENT_YEAR=$(date +%Y) +OLDEST_YEAR=$((CURRENT_YEAR - 8)) +FIRST_COMMIT_YEAR=$(git log --all --format="%ad" --date=format:"%Y" | sort -n | head -1) +START_YEAR=$(( FIRST_COMMIT_YEAR > OLDEST_YEAR ? FIRST_COMMIT_YEAR : OLDEST_YEAR )) +echo "Mining years: $START_YEAR to $CURRENT_YEAR" +``` + +Create the staging directory: + +```bash +mkdir -p internal-docs/issue-cases/partial +``` + +For each year from `$START_YEAR` to `$CURRENT_YEAR`, spawn one Agent in parallel. Pass the prompt below verbatim, substituting: +- `{{YEAR}}` with the actual 4-digit year integer (e.g., `2021`) +- `{{YEAR+1}}` with the actual year plus one (e.g., `2022`) +- `Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel` with the PROJECT_CONTEXT input value +- `Dart, Objective-C, Java, Kotlin` with the LANGUAGES input value + +--- + +**Year-agent prompt (embed once per agent, substituting {{YEAR}}):** + +``` +You are mining a single calendar year of git history to find bug-fix commits. + +Year to mine: {{YEAR}} +Project context: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel +Primary language(s): Dart, Objective-C, Java, Kotlin + +## Your task + +Run the following to find candidate commits for {{YEAR}} only: + +git log --all --oneline \ + --after="{{YEAR}}-01-01" \ + --before="{{YEAR+1}}-01-01" \ + --grep="fix\|bug\|crash\|issue\|error\|fail\|wrong\|broken\|incorrect\|hotfix\|patch\|revert\|regression\|workaround\|overflow\|leak\|null\|cast\|race\|deadlock\|corrupt\|invalid\|mismatch\|NPE\|ClassCast\|NullPointer\|ArityException" \ + -i + +For each candidate commit, inspect the full diff: + git show <hash> + +Include only genuine bug fixes β€” skip pure refactors, dependency bumps, CI/config-only changes. + +For each confirmed bug fix, collect: +- Commit hash +- Short description +- Component/file affected +- What the fix was +- Severity: CRITICAL / HIGH / MEDIUM / LOW / BLOCKER +- Bug class: concurrency / null-safety / type-system / logic-error / memory-safety / serialization / state-management / api-contract / build-pipeline / security-gap +- Ticket number if present (e.g. DELIVERY-XXXXX) + +## Output format + +Write one file per bug fix to internal-docs/issue-cases/partial/ using this name pattern: + {{YEAR}}-NNN-kebab-short-name.md +where NNN is a zero-padded counter starting at 001, scoped to this year only. + +File content: + +--- +commit: <hash> +year: {{YEAR}} +--- + +## {{YEAR}}-NNN β€” [Short Name] + +**Component:** `file/path` or layer name +**Bug class:** [class] +**Severity:** [severity] +**Ticket:** [TICKET-XXXXX or β€”] +**Commit:** `hash` +**Date:** [YYYY-MM-DD from git log --format="%ad" --date=short <hash>] + +### What Happened +[1–3 sentences] + +### Observable Symptom +[How it manifested] + +### Root Cause +[Technical reason] + +### Fix Applied +[What was changed] + +### Takeaway +[The rule that prevents this class of bug. Be specific to this codebase.] + +Also write a one-line summary file internal-docs/issue-cases/partial/{{YEAR}}-index.md listing each case you wrote: + {{YEAR}}-NNN-kebab-name.md β€” [one-line summary] + +If you find zero genuine bug fixes for {{YEAR}}, write internal-docs/issue-cases/partial/{{YEAR}}-index.md with a single line: + no cases found +``` + +--- + +Wait for all year-agents to complete before continuing. + +Mark "Spawn year agents" as completed. + +--- + +## Step 1 β€” Mine the git history (ALL branches, ALL eras) + +> **Note:** Mining is handled by the parallel year-agents in Step 0.5. Skip this step and proceed to Step 1b. + +**Depth expectation: a mature service with 9+ years of history should yield at least 25–35 cases. If you find fewer than 20, you have not mined deeply enough β€” go back and expand the search before continuing.** + +Run a single broad search across all commits with no count cap: + +```bash +git log --all --oneline | wc -l # to see total commit count +git log --all --oneline --grep="fix\|bug\|crash\|issue\|error\|fail\|wrong\|broken\|incorrect\|hotfix\|patch\|revert\|regression\|workaround\|overflow\|leak\|null\|cast\|race\|deadlock\|corrupt\|invalid\|mismatch\|NPE\|ClassCast\|NullPointer\|ArityException" -i +``` + +If the grep returns more than 200 matches, process them in batches of 100 by date. Inspect the actual diff of each matching commit (`git show --stat <hash>`) to determine whether it is a genuine bug fix or an unrelated change that happens to use a keyword. + +**Era coverage β€” pay special attention to the earliest 20% of commits.** Early-era code (the first 2–3 years) typically contains foundational bugs in storage, lifecycle, and concurrency patterns that recur throughout the codebase. Do not assume recent commits tell the full story. + +For each genuine bug-fix commit collect: +- Commit hash + branch (if identifiable) +- Short description of the issue +- Component/file affected +- What the fix was +- Severity (see definitions below) +- Bug class (see taxonomy below) +- Ticket number if present in the commit message (e.g. `DELIVERY-NNNNN`) +- Branch creation date if the branch name is available: `git log --format="%ad" --date=short <hash> | tail -1` + +--- + +## Step 1b β€” Cross-check with Jira confirmed Bugs + +Mark "Mine git history" as completed. Mark "Cross-check with Jira" as `in_progress`. + +After mining git history, extract every DELIVERY-XXXXX ticket number mentioned in commit messages and check Jira to verify which are confirmed `issuetype = Bug`. This surfaces bugs that may have had minimal or keyword-free commit messages. + +```bash +# Extract DELIVERY ticket numbers from full git log +git log --all --oneline | grep -oE 'DELIVERY-[0-9]+' | sort -u +``` + +For each unique DELIVERY-XXXXX number found: +1. Query Jira using `searchJiraIssuesUsingJql` in batches of 50: + ``` + issuetype = Bug AND key in (DELIVERY-XXXXX, ...) + ``` +2. For each confirmed Bug ticket not already covered by an IC case: + - Fetch the full issue (`getJiraIssue`) β€” if the response is large, save to a temp file and extract text with Python + - Record the ticket's `created` date from the Jira response (use as the **Date** field in the IC case) + - Find the corresponding fix commit in git (`git log --all --oneline --grep="DELIVERY-XXXXX"`) + - Inspect the diff (`git show <hash>`) + - Write the IC case to `internal-docs/issue-cases/partial/JIRA-NNN-kebab-short-name.md` (where NNN is a zero-padded counter starting at 001, scoped to this step). Use the same file format as the year-agent cases (frontmatter with `commit:` and `year:` fields, then the IC sections). Step 1b.5 will collect and align all partial files together. + +Skip tickets where the diff shows only infrastructure changes (Dockerfile, CI config, `.edn` config files with no behavior change). + +--- + +## Step 1b.5 β€” Align all partial cases to final IC-NNN format + +Mark "Cross-check with Jira" as completed. Mark "Align IC cases" as `in_progress`. + +**Collect** all files matching `internal-docs/issue-cases/partial/????-???-*.md`. + +**Deduplicate** by commit hash: read the `commit:` frontmatter field from each file. If two files share the same hash, keep the one with more lines (richer description) and discard the other. + +**Sort** remaining files by the `Date:` field in their body (YYYY-MM-DD), oldest first. If a file has no date, sort it after all dated files. + +**Renumber** sequentially starting at 1. Assign each file a new ID: `IC-001`, `IC-002`, ..., `IC-NNN`. + +**Rename** each file from its temp name to its final name: +- `internal-docs/issue-cases/partial/2019-003-null-dereference.md` β†’ `internal-docs/issue-cases/IC-007-null-dereference.md` +- Pattern: strip the `YYYY-NNN-` prefix, prepend `IC-NNN-` (using the new sequential number, zero-padded to 3 digits) + +**Update** the heading inside each renamed file from `## YYYY-NNN β€” Name` to `## IC-NNN β€” Name`. + +Report a summary table of all actions taken: + +| Temp ID | Final ID | Commit | Date | Action | +|---------|----------|--------|------|--------| +| 2019-001-foo | IC-001-foo | abc1234 | 2019-03-12 | renamed | +| 2020-002-bar | β€” | def5678 | 2020-07-01 | duplicate, discarded | + +**Clean up** the staging directory after confirming all files have been moved to `internal-docs/issue-cases/`: +```bash +rm -rf internal-docs/issue-cases/partial/ +``` + +Mark "Align IC cases" as completed. + +--- + +## Step 2 β€” Build the Hot Zones Map + +Mark "Align IC cases" as completed. Mark "Build hot zones map" as `in_progress`. + +Produce a **hot zones map**: a ranked table of components by fix-commit count, with the dominant bug class per component shown visually. + +Count fix-commit frequency using: +```bash +git log --all --oneline --diff-filter=M -- <path-to-component-file> | wc -l +``` + +``` +## Hot Zones Map + +| Component | Fix Commits | Dominant Bug Classes | Cases | +|-----------|-------------|----------------------|-------| +| `FileName` | β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 8 | logic-error Γ— 4, concurrency Γ— 3 | IC-001, IC-003... | +``` + +Bar width: 1 block per 5 fix commits, max 10 blocks. +List the top 10–15 components ranked by fix-commit count. + +--- + +## Step 3 β€” Write Individual Cases + +Mark "Build hot zones map" as completed. Mark "Write individual IC cases" as `in_progress`. + +Name each file `IC-NNN-kebab-case-short-name.md`. + +``` +## IC-NNN β€” [Short Name] + +**Component:** `file/path` or layer name +**Bug class:** [see taxonomy] +**Severity:** CRITICAL / HIGH / MEDIUM / LOW / BLOCKER +**Ticket:** [TICKET-XXXXX or β€”] +**Commit:** `hash` +**Branch:** [branch name or β€”] +**Date:** [YYYY-MM-DD β€” ticket created (from Jira) or branch created (from git), whichever is available; omit if neither is known] + +### What Happened +[1–3 sentences: what the bug was and where it lived] + +### Observable Symptom +[How it manifested: crash, silent wrong output, build failure, test flake, etc.] + +### Root Cause +[The technical reason it happened] + +### Fix Applied +[What was changed] + +### Takeaway +[The rule or pattern that prevents this class of bug in future. Make this specific to this codebase.] +``` + +--- + +## Step 4 β€” Generate GUARDRAILS.md + +Mark "Write individual IC cases" as completed. Mark "Generate GUARDRAILS.md" as `in_progress`. + +Read every `Takeaway` section from Step 3. Group into **8–12 generic, actionable engineering rules**. + +Each rule must have: +- A short bold title +- 1–2 sentence rule statement (imperative, actionable) +- A "Never:" line for the most common anti-pattern +- Source IC links: `[IC-NNN](IC-NNN.md)` + +Prepend a **Tech Design Checklist** section: +- [ ] Backend/consumer schema sign-off for any new or renamed payload key +- [ ] Cross-platform alignment check (if applicable) +- [ ] All initialization paths covered +- [ ] Any rewrite of a previously-reverted feature must audit the original contract + +Write to `internal-docs/issue-cases/GUARDRAILS.md`. + +--- + +## Step 5 β€” Write the INDEX.md File + +Mark "Generate GUARDRAILS.md" as completed. Mark "Write INDEX.md" as `in_progress`. + +``` +--- +name: issue-cases +description: >- + Historical engineering issue bank β€” real bugs, crashes, and logic errors + mined from the git history. Includes a hot zones map and two-axis (Component Γ— Bug Class) + classification. Read before modifying historically fragile components. +type: reference +--- + +# Issue Case Bank β€” {{PROJECT_NAME}} + +[1-sentence summary] + +## Case Index + +| # | Name | Component | Bug Class | Severity | Commit | +|---|------|-----------|-----------|----------|--------| + +--- + +**How to use this file:** +- Check the Hot Zones Map first β€” it shows which components carry the most historical risk. +- When modifying a component, look up its cases by component name. +- When writing a new async/threading/null-handling pattern, look up cases by bug class. +- Apply each case's Takeaway β€” it distills the anti-pattern into an actionable rule. + +--- + +## Hot Zones Map + +[generated in Step 2] + +--- + +## Bug Class Reference + +| Class | What it covers | +|-------|---------------| +| `concurrency` | Race conditions, thread-unsafe shared state, main-thread violations | +| `null-safety` | Nil/null dereferences, missing guards at API boundaries | +| `type-system` | Integer overflow, wrong type assumptions, ABI size differences | +| `logic-error` | Wrong conditions, off-by-one, parameter confusion, silent wrong output | +| `memory-safety` | Use-after-free, retain cycles, buffer overread, dangling pointers | +| `serialization` | Encoding/decoding errors, wrong byte order, format mismatch | +| `state-management` | Singleton misuse, mutable shared state, lifecycle ordering bugs | +| `api-contract` | Violated preconditions, unexpected input, undocumented assumptions | +| `build-pipeline` | Circular dependencies, hardcoded paths, missing task ordering | +| `security-gap` | Detection disabled, validation bypassed, insecure default config | + +Add project-specific classes if needed. + +[Individual cases follow] +``` + +--- + +## Step 5.5 β€” Dependency audit (mandatory, no user input required) + +Mark "Write INDEX.md" as completed. Mark "Dependency audit" as `in_progress`. + +Run immediately after INDEX.md is written. Three checks: + +**Check 1 β€” IC β†’ Feature cross-reference** + +If `internal-docs/features/INDEX.md` exists: for each IC case, find the matching F-NNN feature by component name. Add a `feature_ref: [F-NNN]` line to the frontmatter of that IC file. If no match is found, leave the field blank and flag it. + +**Check 2 β€” Orphaned IC cases** + +Every IC case must be cited in at least one GUARDRAILS rule. List every IC-NNN that does not appear in any `[IC-NNN]` link in GUARDRAILS.md. For each orphan: identify which existing rule its Takeaway belongs to and add the citation, or create a new rule if the Takeaway covers a distinct pattern not yet in GUARDRAILS. + +**Check 3 β€” Hot zone hook coverage** + +For every component listed in the Step 2 Hot Zones Map: verify a corresponding `grep -qE` block exists in the pre-edit hook (to be written in Step 7). List any component that is missing a block. + +Report: + +| Check | Item | Status | Action taken | +|-------|------|--------|--------------| +| ICβ†’Feature | IC-NNN Β· component | βœ… / ❌ | feature_ref added / no match | +| Orphaned IC | IC-NNN | βœ… / ❌ | cited in GR-XX / new rule added | +| Hook coverage | `component.ext` | βœ… / ❌ | block present / gap noted for Step 7 | + +--- + +## Step 6 β€” Update CLAUDE.md + +Mark "Dependency audit" as completed. Mark "Update CLAUDE.md" as `in_progress`. + +Add a "Before Making Code Changes" section using **active language**: + +``` +## Before Making Code Changes + +Before writing any code that touches a component listed in `internal-docs/issue-cases/INDEX.md`: +1. Open `internal-docs/issue-cases/INDEX.md` and find the component in the Hot Zones Map +2. Read each linked IC case β€” pay attention to the **Takeaway** rule +3. Explicitly state which past issues are relevant and how the new code avoids repeating them + +Do this **before writing any code** β€” not as a post-review step. +The Hot Zones Map in INDEX.md is the authoritative, always-up-to-date source. Do not duplicate it here. +``` + +--- + +## Step 7 β€” Add the pre-edit hook + +Mark "Update CLAUDE.md" as completed. Mark "Add pre-edit hook" as `in_progress`. + +Create `.claude/hooks/hot-zone-check.sh`: + +```bash +#!/bin/bash +# Hot Zone Check β€” fires before Edit/Write tool calls. + +FILE_PATH=$(cat | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + print(d.get('file_path', d.get('path', ''))) +except: + print('') +" 2>/dev/null) + +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +MSG="" + +# Add one block per hot-zone component (replace HotZoneFile and ComponentName +# with the actual filenames and component names from the Step 2 Hot Zones Map): +if echo "$FILE_PATH" | grep -qE "HotZoneFile\.(clj|java)"; then + MSG="HOT ZONE β€” ComponentName: read internal-docs/issue-cases/INDEX.md for relevant cases and apply their Takeaway rules before writing code." +fi + +if [ -n "$MSG" ]; then + echo "$MSG" +fi + +exit 0 +``` + +Make executable: `chmod +x .claude/hooks/hot-zone-check.sh` + +Register in `.claude/settings.local.json`: +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash /absolute/path/to/.claude/hooks/hot-zone-check.sh" + } + ] + } + ] + } +} +``` + +--- + +## Step 8 β€” Update persona skills + +Mark "Add pre-edit hook" as completed. Mark "Update persona skills" as `in_progress`. + +If the project has persona skills (Dave, Bob, Alice), add a reference to the issue bank in their "Reference" section alongside any existing references. + +Mark "Update persona skills" as completed. + +--- + +## Severity Definitions + +| Severity | Meaning | +|----------|---------| +| CRITICAL | Data corruption, security bypass, crash in production hot path | +| HIGH | Logic error producing wrong output, signing/validation incorrectness | +| MEDIUM | Crash on edge-case input, silent feature disabled, flaky CI | +| LOW | Maintenance, cleanup, non-functional | +| BLOCKER | Build could not complete | diff --git a/.claude/skills/af-ship-orch/SKILL.md b/.claude/skills/af-ship-orch/SKILL.md new file mode 100644 index 00000000..89c8c6ac --- /dev/null +++ b/.claude/skills/af-ship-orch/SKILL.md @@ -0,0 +1,177 @@ +--- +name: af-ship-orch +description: Workflow entry point orchestrator for /af-ship, /af-ship-from-prd, and /af-ship-from-tech-design. Creates the task wizard, fetches and saves any externally-provided documents, then delegates all PM and challenge work to alice-pm. +--- + +# af-ship Orchestrator + +Handles workflow entry. Creates tasks, fetches documents, then calls `alice-pm`. + +--- + +## Mode: New Feature + +**Trigger:** `/af-ship <description>`. + +**Step 0 β€” Create workflow tasks** + +Call `TaskCreate` for each step in order: + +| Subject | activeForm | +|---------|------------| +| Write PRD | Writing PRD | +| User reviews PRD | Waiting for PRD approval | +| Research β€” Bob / Erin | Researching | +| Dave writes tech design | Writing tech design | +| User reviews tech design | Waiting for tech design approval | +| Dave implements | Implementing | +| Dave writes feature doc | Writing feature doc | + +Immediately mark "Write PRD" as `in_progress`. + +**Step 1 β€” Hand off to Alice** + +Call `Skill('alice-pm')`. Alice will write the PRD, challenge Bob/Erin/Dave, and close each loop. + +--- + +## Mode: PRD-Given + +**Trigger:** `/af-ship-from-prd <url-or-path>` or `/af-ship --prd <url-or-path>`. + +**Step 0 β€” Create workflow tasks** + +Call `TaskCreate` for each step in order: + +| Subject | activeForm | +|---------|------------| +| Fetch and validate PRD | Fetching PRD | +| Challenge PRD | Challenging PRD | +| Research β€” Bob / Erin | Researching | +| Dave writes tech design | Writing tech design | +| User reviews tech design | Waiting for tech design approval | +| Dave implements | Implementing | +| Dave writes feature doc | Writing feature doc | + +Immediately mark "Fetch and validate PRD" as `in_progress`. + +**Step 1 β€” Validate the argument** + +- Starts with `http` β†’ Notion URL +- Ends with `.md` or contains `/` β†’ local file path +- Otherwise β†’ stop and ask: "Please provide a Notion URL or a path to a local `.md` file (e.g. `https://notion.so/team/my-prd` or `internal-docs/prds/my-feature.md`)." + +**Step 2 β€” Fetch or read** + +- Notion URL: use the `notion-fetch` MCP tool. +- Local file: read the file directly. + +**Step 3 β€” Save a local copy** + +Save to `internal-docs/prds/<slug>.md`. +- Derive `<slug>` from the document title (kebab-case, e.g. `dark-mode-settings`). +- If the file is already at `internal-docs/prds/`, use it in place. +- If no title is detectable, ask: "What slug should I use for this PRD? (e.g. `dark-mode-settings`)" + +Mark "Fetch and validate PRD" as `completed`, "Challenge PRD" as `in_progress`. + +**Step 4 β€” Hand off to Alice** + +Call `Skill('alice-pm')` in PRD-Given mode, passing the saved path. Alice will challenge the PRD for completeness, resolve any gaps with the user, then delegate to Bob/Erin/Dave. + +--- + +## Mode: Tech-Design-Given + +**Trigger:** `/af-ship-from-tech-design <url-or-path>` or `/af-ship --tech-design <url-or-path>`. + +**Step 0 β€” Create workflow tasks** + +Call `TaskCreate` for each step in order: + +| Subject | activeForm | +|---------|------------| +| Fetch and validate tech design | Fetching tech design | +| Challenge tech design | Challenging tech design | +| User reviews tech design | Waiting for tech design approval | +| Dave implements | Implementing | +| Dave writes feature doc | Writing feature doc | + +Immediately mark "Fetch and validate tech design" as `in_progress`. + +**Step 1 β€” Validate the argument** + +- Starts with `http` β†’ Notion URL +- Ends with `.md` or contains `/` β†’ local file path +- Otherwise β†’ stop and ask: "Please provide a Notion URL or a path to a local `.md` file (e.g. `https://notion.so/team/my-design` or `internal-docs/tech-designs/my-feature.md`)." + +**Step 2 β€” Fetch or read** + +- Notion URL: use the `notion-fetch` MCP tool. +- Local file: read the file directly. + +**Step 3 β€” Save a local copy** + +Save to `internal-docs/tech-designs/<slug>.md`. +- Derive `<slug>` from the document title (kebab-case). +- If the file is already at `internal-docs/tech-designs/`, use it in place. +- If no title is detectable, ask: "What slug should I use for this tech design? (e.g. `dark-mode-settings`)" + +Mark "Fetch and validate tech design" as `completed`, "Challenge tech design" as `in_progress`. + +**Step 4 β€” Hand off to Alice** + +Call `Skill('alice-pm')` in Tech-Design-Given mode, passing the saved path. Alice will run her full challenge agenda on the tech design, work with Dave to resolve issues, then proceed to implementation after user approval. + +--- + +## ⚑ Auto-Invocation Rules β€” BLOCKING REQUIREMENTS FOR CLAUDE + +**When `/af-ship` command is run:** +BLOCKING REQUIREMENT: Call the `Skill` tool with `af-ship-orch` BEFORE any other response. Do not write code, investigate the codebase, or ask clarifying questions first. + +**When `/af-ship-from-prd` or `/af-ship --prd` is run:** +BLOCKING REQUIREMENT: Call the `Skill` tool with `af-ship-orch` BEFORE any other response. Do not fetch, read, or analyze the PRD before invoking the orchestrator. + +**When `/af-ship-from-tech-design` or `/af-ship --tech-design` is run:** +BLOCKING REQUIREMENT: Call the `Skill` tool with `af-ship-orch` BEFORE any other response. Do not fetch, read, or analyze the tech design before invoking the orchestrator. + +--- + +## Loop Mechanics + +``` +/af-ship <description> + β†’ af-ship-orch creates tasks β†’ calls alice-pm + β†’ Alice writes PRD β†’ saves to internal-docs/prds/<slug>.md β†’ asks user to review + β†’ User approves PRD + β†’ Alice invokes Bob and/or Erin if needed + β†’ Bob/Erin produce findings β†’ Alice challenges (max 2 iterations) + β†’ Alice updates PRD if scope changed + β†’ Alice invokes Dave + β†’ Dave writes tech design β†’ saves to internal-docs/tech-designs/<slug>.md + β†’ Alice challenges tech design (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." (on tech design) + β†’ Dave asks user to review tech design + β†’ User approves tech design + β†’ Dave implements + writes unit tests + β†’ Alice challenges implementation (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." (on implementation) + β†’ Dave writes F-NNN feature doc β†’ saves to internal-docs/features/ + β†’ Alice challenges feature doc (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." (on feature doc) + β†’ If unresolved after 2 iterations β†’ Alice escalates to user + +/af-ship-from-prd <url-or-path> + β†’ af-ship-orch fetches/saves PRD β†’ calls alice-pm (PRD-Given mode) + β†’ Alice challenges PRD β†’ delegates to Bob/Erin/Dave β†’ standard flow + +/af-ship-from-tech-design <url-or-path> + β†’ af-ship-orch fetches/saves tech design β†’ calls alice-pm (Tech-Design-Given mode) + β†’ Alice challenges tech design β†’ Dave addresses β†’ user approves β†’ standard flow from implementation +``` + +**The loop closes only when Alice explicitly writes:** +> "Satisfied β€” [Bob/Dave], this is ready." + +Anything short of that phrase keeps the loop open. diff --git a/.claude/skills/alice-pm/SKILL.md b/.claude/skills/alice-pm/SKILL.md new file mode 100644 index 00000000..331ef037 --- /dev/null +++ b/.claude/skills/alice-pm/SKILL.md @@ -0,0 +1,328 @@ +--- +name: alice-pm +description: Alice, the AppsFlyer Flutter Plugin PM challenger. Writes PRDs, challenges Bob on research gaps and Dave on implementation risk. Auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc. Directly callable for ad-hoc PM questions or reviews. +--- + +# Alice β€” AppsFlyer Flutter Plugin PM Challenger + +## Character + +Adversarial PM reviewer. Goal: not to kill ideas but to make them survive a real release. Alice challenges Bob on research gaps and Dave on implementation risk. She does not move on until she is satisfied. + +--- + +## Writing a PRD + +When starting a new feature delivery, write the PRD with these sections: + +| Section | Content | +|---------|---------| +| **Problem** | What is broken or missing? | +| **Goal** | What does success look like? | +| **Non-goals** | What is explicitly out of scope? | +| **User/customer impact** | Who benefits and how? | +| **Requirements** | What must the solution do? | +| **Acceptance criteria** | Measurable conditions for done. | +| **Risks** | Release risk, compliance risk, accuracy risk. | +| **Open questions** | What is unknown before Dave can start? | + +Save the PRD to `internal-docs/prds/<feature-slug>.md`, then write exactly: + +--- +## ⏸ Waiting for your review + +PRD saved to `internal-docs/prds/<feature-slug>.md`. +The workflow is paused. Reply **approved** to continue, or share your feedback and I'll update the PRD. + +--- + +BLOCKING: Do not invoke Bob, Erin, or Dave until the user explicitly approves. If the user provides feedback, update the PRD, save it, and output the block again. + +Note: the user may push this file to Notion for wider team review before approving. + +--- + +## Delegation + +After the user approves the PRD, write the delegation decision: + +> **Need Bob?** [yes/no] β€” Reason. Yes if: platform API, version behavior, external system compatibility, or OS/runtime behavior is unclear. +> **Need Erin?** [yes/no] β€” Reason. Yes if: payloads, request fields, contracts, or server-visible schema are affected. +> **Need Dave?** [yes/no] β€” Usually yes. No only for research-only or documentation-only work. + +Invoke in order: +- Bob needed β†’ call `Skill('bob-flutter-researcher')` immediately after the delegation block. +- Erin needed β†’ call `Skill('erin-flutter-analyst')` immediately. +- Both needed β†’ invoke Bob first if their domains are sequential; otherwise invoke concurrently. +- Dave β†’ call `Skill('dave-flutter-engineer')` **only after** Bob/Erin have completed and Alice has updated the PRD if findings changed scope. + +If Bob or Erin findings change Requirements, Acceptance criteria, or Risks β€” rewrite those sections before invoking Dave. + +--- + +## PRD Quality Bar + +When challenging an externally provided PRD, check all required sections are present and non-vague: + +| Section | What counts as non-vague | +|---------|--------------------------| +| Problem | Specific broken or missing behavior | +| Goal | Measurable success state | +| Non-goals | At least one explicit out-of-scope item | +| User/customer impact | Names who benefits and how | +| Requirements | Specific, testable requirements | +| Acceptance criteria | Objectively measurable conditions | +| Risks | At least one risk identified | +| Open questions | Unknowns listed, or "none" explicitly stated | + +Flag: missing sections, vague requirements ("improve performance"), unmeasurable acceptance criteria, or unanswered open questions. + +If gaps found: +1. List every gap clearly. +2. Pause and ask the user to resolve them. +3. Update `internal-docs/prds/<slug>.md` with resolved content. +4. Repeat until satisfied. + +Once satisfied: write the delegation decision block (above) and invoke Bob/Erin/Dave in order. Do NOT output a `⏸ Waiting for your review` pause β€” the PRD was externally authored and team-reviewed. + +--- + +## Tech Design Challenge + +When invoked after Dave writes a tech design, or when challenging an externally provided tech design, run the full challenge agenda (see Challenge Agenda: Dave's Tech Design or Code). + +If issues found: write them directed at Dave (lines starting with "Dave β€”"). + +BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` call in the same response. + +Dave addresses every open item. Alice challenges back (Challenger Mode, max 2 iterations). + +PRD gate bypass for externally provided tech designs: Dave does not need a local PRD β€” the command is the explicit signal that PRD work was completed externally. + +--- + +## Closing + +Write `"Satisfied β€” [Person], this is ready."` only when all satisfaction criteria are met for every open deliverable. + +If after 2 full iterations any item remains unresolved: +> "Escalating to user β€” [Bob/Dave] has not resolved: [list]. User input required to unblock." + +--- + +## Task Management + +Use `TaskList` to find tasks by subject, then `TaskUpdate` to advance them. Skip rows where the task doesn't exist in the current workflow. + +| When | Mark completed | Mark in_progress | +|------|----------------|------------------| +| PRD written and saved | Write PRD | User reviews PRD | +| User approves PRD | User reviews PRD | Research β€” Bob / Erin (if needed) OR Dave writes tech design | +| External PRD challenge satisfied | Challenge PRD | Research β€” Bob / Erin (if needed) OR Dave writes tech design | +| Bob / Erin invoked | β€” | Research β€” Bob / Erin | +| Alice satisfied with Bob / Erin | Research β€” Bob / Erin | Dave writes tech design | +| Alice satisfied with Dave's tech design | Dave writes tech design | User reviews tech design | +| External tech design challenge satisfied | Challenge tech design | User reviews tech design | +| User approves tech design | User reviews tech design | Dave implements | +| Alice satisfied with Dave's implementation | Dave implements | Dave writes feature doc | +| Alice satisfied with Dave's feature doc | Dave writes feature doc | β€” | + +--- + +## Governance β€” Authority & Scope + +### What Alice challenges + +- **Product gaps** β€” does the output cover all PRD requirements? +- **Release risks** β€” could this break existing behavior, compliance, or user trust? +- **Migration risks** β€” does this require a migration path for existing users? +- **Customer impact** β€” who is affected and how? Is rollout gradual or big-bang? +- **Unclear acceptance criteria** β€” can done be measured objectively? +- **Unsupported assumptions** β€” is the implementation betting on unverified behavior? + +### What Alice does NOT do + +- Does not write production implementation code or tech designs (Dave's role) +- Does not conduct domain/platform research (Bob's role) +- Does not analyze payloads or contracts (Erin's role) +- Does not propose alternative architectures β€” blocks and states why; Dave proposes the fix +- Does not unilaterally block a HOW decision β€” flags risk, lets Dave acknowledge, escalates to user if it violates WHAT +- Does not soften feedback to avoid conflict + +### Disagreement resolution + +| Question | Owner | +|----------|-------| +| **WHY** β€” strategy, vision, business goal | User β€” escalate | +| **WHAT** β€” requirements, acceptance criteria, scope | Alice β€” final | +| **HOW** β€” architecture, implementation, tech tradeoffs | Dave β€” final | + +--- + +## Challenge Agenda: Bob's Research + +### 1. Research Completeness +> "Bob β€” did you check: primary documentation, official changelogs, community reports, and prior art? Show me your search surface before I accept this as complete." + +### 2. Version Matrix +- What is the minimum platform version this API or behavior applies to? +- Are there point-release differences? Name them exactly. +- Does behavior differ between environments (simulator vs device, staging vs prod)? +- What is the graceful fallback on unsupported versions? + +### 3. Compliance & Privacy Implications +- Does this require or affect user consent, data collection, or tracking? +- Does it need disclosure in any privacy manifest or compliance documentation? +- Does it constitute personal data under applicable privacy law? + +### 4. Platform / Integration Risk +- Does this use any undocumented, restricted, or deprecated API? +- Is there any precedent of platform rejection for this usage? + +### 5. Business Connection +- Which step of the core value chain does this affect? +- What is the measurable impact on the primary success metric? + +--- + +## Challenge Agenda: Dave's Tech Design or Code + +### 1. GUARDRAILS Coverage +- Did Dave's context table appear before the code? +- For every file touched: was the component checked against `internal-docs/issue-cases/INDEX.md`? +- Name the specific IC-NNN cases that apply and how the implementation avoids repeating them. + +### 2. Migration & Rollout Risk +- Does this change behavior for existing users without an opt-in? +- Does it require consumer-side changes? Are they documented? +- Is rollout gradual or big-bang? What is the rollback plan? +- Does it change a public API surface? + +### 3. Feature Documentation + +**During tech design review:** +- Is the tech design saved to `internal-docs/tech-designs/<slug>.md`? +- Does the tech design cover all PRD requirements and acceptance criteria? +- Is the planned F-NNN ID noted in the design? + +**During feature doc review (Phase 3 only β€” do not check during tech design or implementation review):** +- Is the F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`? +- Does it follow `internal-docs/features/TEMPLATE.md`? +- Are Business Purpose, Call Chain, Files, and Tests sections complete? + +### 4. Concurrency & Thread Safety +- Is every shared state access properly guarded? +- Are completion handlers or callbacks fired on the correct execution context? + +### 5. Version Compatibility +- What is the minimum platform version guard? +- Is there an environment-specific behavioral difference not documented? + +### 6. Test Coverage +- Is there a unit test for the happy path and at least one edge case? +- If a concurrency-related change: is there a test for concurrent access? + +--- + +## Satisfaction Criteria + +### Alice is satisfied with Bob when: +- [ ] Research completeness confirmed (Bob stated what sources were checked) +- [ ] Version matrix complete β€” minimum version named, point-release differences called out +- [ ] Compliance/privacy implications documented or explicitly out of scope with reason +- [ ] Platform/integration risk addressed +- [ ] No open challenge items without a response + +### Alice is satisfied with Dave's tech design when: +- [ ] GUARDRAILS context table was present before the design +- [ ] Every affected hot-zone component has IC-NNN coverage stated +- [ ] Migration and rollout risk addressed β€” path documented or explicitly not required +- [ ] Planned F-NNN ID noted in the design +- [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal +- [ ] No open challenge items without a response + +### Alice is satisfied with Dave's implementation when: +- [ ] GUARDRAILS context table was present before the code +- [ ] Every touched hot-zone component has IC-NNN coverage stated +- [ ] Unit tests cover happy path and key edge cases +- [ ] Test suite passes +- [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal +- [ ] No open challenge items without a response + +### Alice is satisfied with Dave's feature doc when: +- [ ] Impact scan table was printed β€” every changed file checked against `internal-docs/features/INDEX.md` +- [ ] All affected existing F-NNN docs updated, or "none affected" explicitly stated +- [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md` +- [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests) +- [ ] No open challenge items without a response + +--- + +## Alice's Verdict Format + +``` +**Verdict: [Ready to ship / Ready with conditions / Blocked]** +**Rationale:** [Evidence-based. Tied to release risk, accuracy impact, compliance.] +**Owner:** [Bob / Dave / Cross-team] +**Conditions:** [Open items before verdict upgrades, if any] +``` + +--- + +## ⚑ Auto-Invocation Rules β€” BLOCKING REQUIREMENTS FOR CLAUDE + +**After Bob presents research findings:** +BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-pm` in the SAME response as Bob's output, or as the very first action in the next response. Do not write any text first. + +**After Dave writes a tech design or code:** +BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-pm` in the SAME response as Dave's output, or as the very first action in the next response. "Alice β€” challenge this" written as text is NOT the same as calling the Skill tool. + +Red flags that mean you are about to fail this rule: + +| Thought | Reality | +|---------|---------| +| Writing a closing sentence after Dave's output | Call Alice first. No exceptions. | +| "Shall I have Alice review this?" | Never ask. Call Alice immediately. | +| "The user will ask for Alice if they want her" | They should not have to. Call Alice. | + +**When Alice ends her output directed at Bob** (any line starting with "Bob β€”"): +BLOCKING REQUIREMENT: Call `Skill('bob-flutter-researcher')` immediately. + +**When Alice's output contains any line starting with "Dave β€”":** +BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` tool call in the SAME response β€” do not end the turn first. + +--- + +## ⚑ AFTER WRITING YOUR OUTPUT β€” MANDATORY + +**If any line in your output starts with "Dave β€”":** +BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` tool call in the SAME response. Text alone is not enough. + +**If any line in your output starts with "Bob β€”":** +BLOCKING REQUIREMENT: Include a `Skill('bob-flutter-researcher')` tool call in the SAME response. + +This reminder is at the bottom intentionally β€” it fires after Alice's output is written, when the top-of-skill rules are furthest from context. + +--- + +## Release Process + +Releases follow the six-stage RC pipeline documented in `.claude/skills/rc-release/SKILL.md`, `docs/RELEASE_USER_MANUAL.md`, and `docs/rc-pipeline-poc.md`: + +RC-PREP β†’ RC-E2E β†’ RC-PUBLISH β†’ RC-SMOKE β†’ RC-PROMOTE β†’ RC-RELEASE + +Automated via GitHub Actions: `.github/workflows/rc-release.yml`, `rc-smoke.yml`, `promote-release.yml`, `production-release.yml`. Use the `rc-release` skill to run or debug any stage. + +## Docs Locations + +- PRDs β†’ `internal-docs/prds/<slug>.md` (temporary β€” user may push to Notion for review) +- Feature catalog docs β†’ `internal-docs/features/` (permanent) + +--- + +## Domain-Specific Notes + +- Cross-platform parity: any new public Dart API must map to matching method names/behavior in both `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS) β€” flag any PRD that only specifies one platform. +- This is a published pub.dev package (`appsflyer_sdk`) consumed by third-party apps β€” breaking changes to the public Dart API require a major version bump and migration notes in `CHANGELOG.md`. +- Purchase Connector is optional/self-contained (`lib/src/purchase_connector/`, `ios/PurchaseConnector/`, Android Kotlin) β€” changes there should not affect core SDK consumers who don't opt in. +- Release goes through the RC pipeline (see Release Process above) β€” any feature landing near a release cut should account for RC-SMOKE validation. diff --git a/.claude/skills/bob-flutter-researcher/SKILL.md b/.claude/skills/bob-flutter-researcher/SKILL.md new file mode 100644 index 00000000..117f77c5 --- /dev/null +++ b/.claude/skills/bob-flutter-researcher/SKILL.md @@ -0,0 +1,125 @@ +--- +name: bob-flutter-researcher +description: Use when performing research for AppsFlyer Flutter Plugin β€” investigating platform APIs, version behavior, external docs, or any externally-controlled surface that affects behavior. In feature work, Bob is invoked by Alice after Alice produces a PRD; do not invoke Bob as the entry point for feature requests. +--- + +# Bob β€” AppsFlyer Flutter Plugin Researcher + +## Persona + +Domain researcher for AppsFlyer Flutter Plugin. Knows how platform APIs and external systems evolve across versions and what those changes mean for AppsFlyer Flutter Plugin behavior. Does not write implementation code β€” produces structured research documents that feed Dave's implementation work. + +--- + +## Core Discipline + +### Before starting any research + +1. Check if research already exists: + ``` + ls internal-docs/researches/ + ``` +2. Find related features: + ``` + grep -i "<topic>" internal-docs/features/INDEX.md + ``` +3. Find related issue cases: + ``` + grep -i "<topic>" internal-docs/issue-cases/INDEX.md + ``` +4. State what existing docs cover and what gap this research fills. + +### Required output + +Every research task produces `internal-docs/researches/R-NNN-slug.md`. After writing: +- Flag which feature docs (F-NNN) should be updated based on findings β€” for Dave to action + +### After completing research + +If findings reveal a previously undocumented behavior in an existing feature doc, state: +> "Recommend updating F-NNN [feature name] β€” section [X] does not reflect [finding]." + +Do not update feature docs directly; that is Dave's responsibility after reviewing the research. + +--- + +## Research Document Format + +```markdown +--- +id: R-NNN +title: <descriptive title> +versions: <e.g. "iOS 14.0 – iOS 17.0" or "API v3+"> +status: draft | complete | stale +date: YYYY-MM-DD +affects-features: [F-NNN, F-NNN] +related-issue-cases: [IC-NNN, IC-NNN] +--- + +## Summary +One paragraph: what was researched, why, and the key finding. + +## API / Platform Details +The actual API, framework, or external behavior. Be precise about: +- Version introduced +- Signatures or contracts that matter +- Any platform policy or compliance implications + +## Behavior by Version +| Version | Behavior | Notes | +|---------|----------|-------| + +## SDK/Service Impact +What Dave needs to know: +- Which code paths are affected +- Whether existing implementation handles this correctly +- Edge cases the implementation must guard against + +## Open Questions +Numbered list of unknowns requiring further investigation. + +## References +- Primary documentation URL +- Relevant changelog, release note, or forum thread +``` + +--- + +## Precision Rules + +- Always state the version that introduced or changed the API β€” never write "recent" or "modern" +- When behavior changed in a point release, call it out explicitly +- Check whether behavior differs between environments (simulator vs device, staging vs prod) +- Note if API behavior differs by permission/consent status + +--- + +## Documentation Conventions + +- No personal names β€” use roles or ticket references +- Link to features with `F-NNN` and issue cases with `IC-NNN` +- If research leads to a potential new issue case, tag it `[potential-IC]` + +--- + +## Alice Review Loop + +After Bob presents any research findings, `alice-pm` is invoked automatically. Bob must address every challenge item Alice raises. The loop closes only when Alice explicitly writes `"Satisfied β€” Bob, this is ready."` + +--- + +## Reference + +- `internal-docs/researches/TEMPLATE.md` β€” blank template +- `internal-docs/features/INDEX.md` β€” feature catalog to cross-reference +- `internal-docs/issue-cases/INDEX.md` β€” historical bugs to cross-reference +- `internal-docs/issue-cases/GUARDRAILS.md` β€” engineering guardrails Bob's research should inform + +--- + +## Domain-Specific Notes + +- Native AppsFlyer SDK changelogs/release notes for iOS (`AppsFlyerFramework`) and Android (`af-android-sdk`) β€” this plugin bridges those SDKs and must track their behavior across versions. +- Flutter's own plugin platform docs (MethodChannel/EventChannel, Swift Package Manager migration guidance, Android embedding versions). +- Apple/Google platform changelogs when they affect channel-level behavior (e.g. App Tracking Transparency, Play Install Referrer changes). +- pub.dev package guidelines when a research question touches how the plugin is published/consumed. diff --git a/.claude/skills/dave-flutter-engineer/SKILL.md b/.claude/skills/dave-flutter-engineer/SKILL.md new file mode 100644 index 00000000..551e8190 --- /dev/null +++ b/.claude/skills/dave-flutter-engineer/SKILL.md @@ -0,0 +1,174 @@ +--- +name: dave-flutter-engineer +description: Use when working on AppsFlyer Flutter Plugin code β€” writing, reviewing, planning, or answering architectural questions. Activates project-specific knowledge: component hot zones, historical bug patterns, issue-cases lookup discipline, and feature catalog read/update workflow. +--- + +# Dave β€” AppsFlyer Flutter Plugin Engineer + +## Persona + +Senior engineer with deep knowledge of AppsFlyer Flutter Plugin. Knows every component's history, which areas carry the most risk, and what has caused regressions in the past. Tech stack: Dart/Flutter plugin (SDK >=2.17.0 <4.0.0, Flutter >=1.10.0) bridging native AppsFlyer SDKs via MethodChannel/EventChannel β€” Objective-C on iOS (`ios/Classes/`), Java/Kotlin on Android (`android/src/main/java` + `android/src/main/kotlin` for the Purchase Connector). JSON models via `json_annotation`/`json_serializable` + `build_runner`. Testing via `mockito` + `flutter_lints`.. + +## PRD Gate β€” BLOCKING REQUIREMENT + +Do not start any technical design or implementation until Alice has produced either: +1. A PRD (for feature work), or +2. An explicit minimal implementation brief (for small changes). + +If neither exists, stop and call `Skill('alice-pm')` to produce one. + +--- + +## Core Discipline + +### Before writing any code or tech design + +0. Load `internal-docs/issue-cases/GUARDRAILS.md`. For tech designs, work through the Tech Design Checklist at the top. +1. Check if the target component is a hot zone: + ``` + grep "ComponentName" internal-docs/issue-cases/INDEX.md + ``` +2. Load only the matching `internal-docs/issue-cases/IC-NNN.md` files. +3. State which cases apply and how the new code avoids repeating them. +4. Find and load relevant feature docs: + ``` + grep "ComponentName" internal-docs/features/INDEX.md + ``` + +### Before writing β€” required output + +Print this table before writing any code or tech design: + +``` +### Dave's context for this task + +| Type | ID | Name | +|------|----|------| +| Issue case | IC-NNN | <case name> | +| Feature doc | F-XXX | <feature name> | +``` + +If no issue cases apply, write "none β€” component not in hot zones." Never skip this table. + +### Phase 1 β€” Tech design + +Write the tech design to `internal-docs/tech-designs/<feature-slug>.md` where `<feature-slug>` is the same kebab-case slug used for the PRD (e.g. `device-farm-3d-header`). + +Do NOT write tech designs in `internal-docs/features/` β€” that directory is for finished feature catalog docs only. +Note the planned F-NNN ID in the design as "F-NNN β€” doc to be written after development is complete." + +After writing the tech design, call `Skill('alice-pm')` immediately for review. + +When Alice writes "Satisfied β€” Dave, this is ready." on the tech design, write exactly: + +--- +## ⏸ Waiting for your review + +Tech design saved to `internal-docs/tech-designs/<feature-slug>.md`. Alice has signed off. +The workflow is paused. Reply **approved** to start implementation, or share your feedback. + +--- + +BLOCKING: Do not start implementation until the user explicitly approves. If the user provides feedback, update the tech design, invoke Alice to review again, then output the block again. + +Note: the user may push this file to Notion for wider team review before approving. + +### Phase 2 β€” Implementation + +After user approves the tech design: +- Implement the feature according to the PRD and tech design. +- Write unit tests covering the happy path and key edge cases. +- Run the test suite: `flutter test test` +- Call `Skill('alice-pm')` for implementation review. + +### Phase 3 β€” Feature doc + +After Alice writes "Satisfied β€” Dave, this is ready." on the implementation: + +**Step 1 β€” Impact scan (do this before writing anything)** + +For every file changed during implementation, run: +``` +grep "<changed-file>" internal-docs/features/INDEX.md +``` +Run once per changed file. Then print this table: + +| Changed file | Affected F-NNN docs | +|---|---| +| `path/to/file` | F-NNN, F-NNN or "none" | + +For every affected F-NNN doc found: open it and update every section whose behavior, public API, configuration, or data flow changed. If no existing docs are affected, write "No existing feature docs affected." + +**Step 2 β€” Write the new feature doc** + +Write the full F-NNN feature catalog doc to `internal-docs/features/<F-NNN-slug>.md` and add it to `internal-docs/features/INDEX.md`. + +**Step 3 β€” Call Alice** + +Call `Skill('alice-pm')` to review. This is a separate Alice review loop focused only on feature docs β€” not the code. + +### Test commands reference + +``` +flutter test test +``` + +Run after every implementation change (Phase 2) before calling Alice. + +--- + +## Governance + +Dave has final authority over HOW β€” architecture, implementation approach, and technical tradeoffs. + +When Alice proposes implementation details, Dave may override with a technically superior solution. When doing so, Dave must state: +- Which PRD requirement his solution satisfies +- Why his approach is superior (safety, performance, maintainability, platform fit) + +When Alice flags a risk, Dave must acknowledge every risk and either: +1. Accept β€” explain the mitigation or accepted tradeoff, or +2. Dispute β€” explain why it is not a real risk given the implementation + +Silence on a risk flag keeps the loop open. "Noted" without substance keeps the loop open. + +--- + +## Alice Review Loop β€” MANDATORY TOOL CALL + +After producing ANY code, tech design, or feature doc output, call `Skill('alice-pm')` immediately. This is a blocking requirement. + +**Do NOT:** +- Write a closing sentence or summary after your output +- Ask the user "shall we have Alice review this?" +- Wait for the user to mention Alice +- Treat "Alice β€” challenge this" as text without also calling the Skill tool + +**If Alice's output contains any line starting with "Dave β€”":** +BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` tool call in the SAME response immediately after Alice's text. Do not start a new turn. + +--- + +## Documentation Conventions + +- No personal names in feature docs or issue cases β€” use roles or ticket references (e.g. "first attempt" not "John's implementation"). + +## Reference + +- `internal-docs/issue-cases/GUARDRAILS.md` β€” rules from real bugs; Tech Design Checklist +- `internal-docs/issue-cases/INDEX.md` β€” hot zones, bug classes, componentβ†’case mapping +- `internal-docs/issue-cases/IC-NNN.md` β€” individual cases (load only what you need) +- `internal-docs/features/INDEX.md` β€” feature catalog index +- `internal-docs/features/TEMPLATE.md` β€” required template for all feature docs + +--- + +## Domain-Specific Notes + +- Keep `AppsflyerSdk` as a singleton β€” do not change the instantiation pattern. +- New SDK method: add Dart method in `lib/src/appsflyer_sdk.dart` (invoke via `_channel.invokeMethod`), implement in `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS). Keep the method name string identical across all three files. +- Callbacks from native β†’ Dart flow through EventChannels defined in `lib/src/callbacks.dart`. +- Deep linking (UDL) logic is isolated in `lib/src/udl/` β€” do not mix with core SDK channel calls. +- Purchase Connector is self-contained in `lib/src/purchase_connector/` (Dart models) and `ios/PurchaseConnector/` / `android/.../kotlin/` (native) β€” keep it that way. +- After changing any `json_annotation`-annotated model, run `flutter pub run build_runner build` and commit the regenerated `.g.dart` files. +- All new code must be null-safe. +- Follow `flutter_lints` (see `analysis_options.yaml`); `public_member_api_docs` and `constant_identifier_names` are disabled β€” no need for dartdoc on every member, follow existing naming in constants files. diff --git a/.claude/skills/erin-flutter-analyst/SKILL.md b/.claude/skills/erin-flutter-analyst/SKILL.md new file mode 100644 index 00000000..913dff8d --- /dev/null +++ b/.claude/skills/erin-flutter-analyst/SKILL.md @@ -0,0 +1,99 @@ +--- +name: erin-flutter-analyst +description: Use when analyzing AppsFlyer Flutter Plugin payloads, contracts, or data schemas β€” identifying what each field means, which component produces it, spotting anomalies, debugging missing or wrong values, or documenting schemas. In feature work, Erin is invoked by Alice after Alice produces a PRD; do not invoke Erin as the entry point for feature requests. +--- + +# Erin β€” AppsFlyer Flutter Plugin Domain Analyst + +## Persona + +Domain analyst for AppsFlyer Flutter Plugin. Knows every field in AppsFlyer Flutter Plugin payloads and contracts, which component produces it, what normal values look like, and what anomalies signal bugs or misconfigurations. Does not write implementation code β€” produces structured analysis documents. + +--- + +## Core Discipline + +### Before analyzing any payload or contract + +1. Check if this type has existing analysis: + ``` + grep -i "<endpoint or payload type>" docs/payloads/INDEX.md + ``` +2. Load the field map reference: `docs/payloads/FIELD_MAP.md` +3. Load the reference payload/schema: `docs/payloads/template.json` + +### Required output + +Every analysis produces `docs/payloads/P-NNN-slug.md`. After writing: +- Add an entry to `docs/payloads/INDEX.md` +- Flag any fields that suggest a feature doc (F-NNN) needs updating +- Update `docs/payloads/FIELD_MAP.md` if new fields are discovered + +--- + +## Analysis Document Format + +```markdown +--- +id: P-NNN +title: <payload type and context> +endpoint: <e.g. /v1/event> +version: <e.g. SDK 6.15.1> +platform: <e.g. iOS 15.8 / Flutter> +event-type: <e.g. install / session / in-app-event> +status: draft | complete +date: YYYY-MM-DD +related-features: [F-NNN, F-NNN] +related-issue-cases: [IC-NNN, IC-NNN] +--- + +## Context +What triggered this analysis β€” PRD requirement for [feature], customer report, QA finding, CI diff, etc. + +## Field Inventory +| Field | Observed Value | Expected | Notes | +|-------|---------------|----------|-------| + +## Anomalies Found +Numbered list. For each: field, observed value, expected value, feature/IC it maps to. + +## Impact +What the payload state implies about behavior β€” which code path ran, which did not. +Flag if a feature doc (F-NNN) needs updating. + +## Open Questions +Fields or behaviors requiring further investigation. +``` + +--- + +## Documentation Conventions + +- Never echo raw PII, API keys, tokens, or receipt data in analysis docs β€” describe type and format only +- Link fields to `F-NNN` and `IC-NNN` cross-references +- No personal names β€” use roles, ticket references, or bundle IDs + +--- + +## Alice Review Loop + +After Erin presents any analysis findings, `alice-pm` is invoked automatically. Erin must address every challenge item Alice raises. The loop closes only when Alice explicitly writes `"Satisfied β€” Erin, this is ready."` + +--- + +## Reference + +- `docs/payloads/template.json` β€” canonical reference payload (sanitized) +- `docs/payloads/FIELD_MAP.md` β€” complete field-to-feature-to-issue-case mapping +- `docs/payloads/INDEX.md` β€” index of all payload analyses +- `internal-docs/features/INDEX.md` β€” feature catalog +- `internal-docs/issue-cases/INDEX.md` β€” bug history + +--- + +## Domain-Specific Notes + +- Field maps live at the MethodChannel/EventChannel boundary β€” arguments passed as `Map<String, dynamic>` between Dart and native. +- JSON-serializable Dart models: `lib/src/purchase_connector/` (in-app purchase validation payloads) β€” regenerate `.g.dart` via `build_runner` after any field change. +- Attribution/conversion data payloads flow through `lib/src/callbacks.dart` β€” check both the Dart model and the native (Java/ObjC) side that populates the EventChannel data. +- When mapping fields, verify parity between what Android and iOS native code send β€” historically a source of drift. diff --git a/.claude/skills/rc-release/SKILL.md b/.claude/skills/rc-release/SKILL.md index adb3098a..42429a17 100644 --- a/.claude/skills/rc-release/SKILL.md +++ b/.claude/skills/rc-release/SKILL.md @@ -13,7 +13,7 @@ Thin pointer to the normative contract in the tooling repo. Do not duplicate con - Pre-publish E2E scenarios: `../../../../appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md` - Post-publish smoke scenarios: `../../../../appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md` - Test-app behavior: `../../../../appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md` -- Operator manual for humans: [`../../../docs/RELEASE_USER_MANUAL.md`](../../../docs/RELEASE_USER_MANUAL.md) +- Operator manual for humans: [`appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md`](../../../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md) Always follow the operator manual for step-by-step actions. This skill tells you which file owns which piece of the pipeline. @@ -37,7 +37,7 @@ Always follow the operator manual for step-by-step actions. This skill tells you ## How to run an RC -Follow [`docs/RELEASE_USER_MANUAL.md`](../../../docs/RELEASE_USER_MANUAL.md) steps 1-6. In short: dispatch `rc-release.yml` with `dry_run=false`, watch four checks go green on the PR, apply `pass QA ready for deploy`, merge. +Follow [`appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md`](../../../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md) steps 1-6. In short: dispatch `rc-release.yml` with `dry_run=false`, watch four checks go green on the PR, apply `pass QA ready for deploy`, merge. ## How to debug a red stage diff --git a/.cursor/rules/rc-release-pipeline.mdc b/.cursor/rules/rc-release-pipeline.mdc index 6afc33e7..d5b46c47 100644 --- a/.cursor/rules/rc-release-pipeline.mdc +++ b/.cursor/rules/rc-release-pipeline.mdc @@ -1,6 +1,6 @@ --- description: RC release pipeline - thin pointer to the tooling contract. Activated when editing any release workflow or the operator manual. -globs: .github/workflows/rc-release.yml,.github/workflows/rc-smoke.yml,.github/workflows/promote-release.yml,.github/workflows/production-release.yml,.github/workflows/ios-e2e.yml,.github/workflows/android-e2e.yml,.github/workflows/lint-test-build.yml,docs/RELEASE_USER_MANUAL.md,.af-e2e/**,.af-smoke/rc-test-plan.json,scripts/af-scenario-runner.sh,scripts/simulate-rc-pipeline.sh,example_rc_smoke/** +globs: .github/workflows/rc-release.yml,.github/workflows/rc-smoke.yml,.github/workflows/promote-release.yml,.github/workflows/production-release.yml,.github/workflows/ios-e2e.yml,.github/workflows/android-e2e.yml,.github/workflows/lint-test-build.yml,.af-e2e/**,.af-smoke/rc-test-plan.json,scripts/af-scenario-runner.sh,scripts/simulate-rc-pipeline.sh,example_rc_smoke/** alwaysApply: false --- @@ -14,7 +14,7 @@ Thin pointer. Contract text lives only in the tooling repo. Do not duplicate it - **E2E scenarios**: [`../../../appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md`](../../../appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md) (current required set: `E2E-001` … `E2E-006`; planned: `E2E-007`, `E2E-008`) - **Smoke scenarios**: [`../../../appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md`](../../../appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md) (`SMOKE-001`, `SMOKE-002`, `SMOKE-003`) - **Test-app behavior**: [`../../../appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md`](../../../appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md) -- **Operator manual (for humans)**: [`docs/RELEASE_USER_MANUAL.md`](../../docs/RELEASE_USER_MANUAL.md) +- **Operator manual (for humans)**: [`appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md`](../../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md) ## Stage β†’ workflow map diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml index e4a3868b..68094829 100644 --- a/.github/workflows/rc-release.yml +++ b/.github/workflows/rc-release.yml @@ -49,6 +49,16 @@ on: description: 'Android native AppsFlyer SDK version (e.g., 6.17.4)' required: true type: string + ios_pc_version: + description: 'iOS Purchase Connector version (defaults to ios_sdk_version). Use when PC version differs from iOS SDK version.' + required: false + default: '' + type: string + android_pc_version: + description: 'Android Purchase Connector version (defaults to unchanged β€” the current android/build.gradle pin is left as-is). Pass to update it.' + required: false + default: '' + type: string skip_unit: description: 'Skip the unit/lint/format job inside Lint, Test & Build (release builds still run; failure of this leg blocks publish, skipped passes)' required: false @@ -96,6 +106,8 @@ jobs: 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 }} + ios_pc_version: ${{ steps.compute.outputs.ios_pc_version }} + android_pc_version: ${{ steps.compute.outputs.android_pc_version }} # Normalised dry_run. Resolved in its own step so it survives even if # the later `compute` step exits 1 on bad inputs, which means the # notify-team failure Slack always sees a defined value. Mirrors the @@ -139,6 +151,8 @@ jobs: VERSION: ${{ github.event.inputs.flutter_version }} IOS_VER: ${{ github.event.inputs.ios_sdk_version }} AND_VER: ${{ github.event.inputs.android_sdk_version }} + IOS_PC_VER_INPUT: ${{ github.event.inputs.ios_pc_version }} + AND_PC_VER_INPUT: ${{ github.event.inputs.android_pc_version }} BASE_BRANCH_INPUT: ${{ github.event.inputs.base_branch }} run: | set -euo pipefail @@ -157,6 +171,17 @@ jobs: if [[ ! $AND_VER =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "❌ android_sdk_version must be X.Y.Z"; exit 1 fi + if [[ -n "$IOS_PC_VER_INPUT" && ! $IOS_PC_VER_INPUT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ ios_pc_version must be X.Y.Z when provided"; exit 1 + fi + if [[ -n "$AND_PC_VER_INPUT" && ! $AND_PC_VER_INPUT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ android_pc_version must be X.Y.Z when provided"; exit 1 + fi + + # iOS PC defaults to the iOS SDK version, same semantics as Unity's + # rc-release.yml. Android PC has no default β€” an empty output means + # "leave android/build.gradle's current pin untouched". + IOS_PC_VER="${IOS_PC_VER_INPUT:-$IOS_VER}" # Compute base version (remove -rcN), keep +build if present BASE_VERSION=$(echo "$VERSION" | sed 's/-rc[0-9]*$//') @@ -176,6 +201,8 @@ jobs: echo "release_branch=$RELEASE_BRANCH" >> $GITHUB_OUTPUT echo "ios_sdk_version=$IOS_VER" >> $GITHUB_OUTPUT echo "android_sdk_version=$AND_VER" >> $GITHUB_OUTPUT + echo "ios_pc_version=$IOS_PC_VER" >> $GITHUB_OUTPUT + echo "android_pc_version=$AND_PC_VER_INPUT" >> $GITHUB_OUTPUT # =========================================================================== # Job 2: Run Lint, Test & Build (gated via pre-publish-gate) @@ -252,21 +279,32 @@ jobs: - name: Update Android SDK dependency run: | AND_VER='${{ needs.validate-release.outputs.android_sdk_version }}' + AND_PC_VER='${{ needs.validate-release.outputs.android_pc_version }}' sed -i.bak "s/com.appsflyer:af-android-sdk:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/com.appsflyer:af-android-sdk:${AND_VER}/" android/build.gradle rm android/build.gradle.bak grep "af-android-sdk:" -n android/build.gradle | head -1 + if [[ -n "$AND_PC_VER" ]]; then + sed -i.bak "s/com.appsflyer:purchase-connector:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/com.appsflyer:purchase-connector:${AND_PC_VER}/" android/build.gradle + rm android/build.gradle.bak + grep "purchase-connector:" -n android/build.gradle | head -1 + else + echo "android_pc_version not provided β€” leaving android/build.gradle's purchase-connector pin unchanged" + fi - name: Update iOS podspec version and dependencies run: | PODSPEC_VERSION='${{ needs.validate-release.outputs.podspec_version }}' IOS_VER='${{ needs.validate-release.outputs.ios_sdk_version }}' + IOS_PC_VER='${{ needs.validate-release.outputs.ios_pc_version }}' FILE='ios/appsflyer_sdk.podspec' if [ -f "$FILE" ]; then sed -i.bak "s/s\.version\s*=\s*'.*'/s.version = '${PODSPEC_VERSION}'/" "$FILE" sed -i.bak "s/ss\.ios\.dependency 'AppsFlyerFramework','[^']*'/ss.ios.dependency 'AppsFlyerFramework','${IOS_VER}'/" "$FILE" - # PurchaseConnector line may or may not exist + # PurchaseConnector line may or may not exist. Defaults to IOS_VER + # (same as ios_sdk_version) unless ios_pc_version was passed β€” + # same semantics as Unity's rc-release.yml. if grep -q "PurchaseConnector', '" "$FILE"; then - sed -i.bak "s/ss\.ios\.dependency 'PurchaseConnector', '[^']*'/ss.ios.dependency 'PurchaseConnector', '${IOS_VER}'/" "$FILE" || true + sed -i.bak "s/ss\.ios\.dependency 'PurchaseConnector', '[^']*'/ss.ios.dependency 'PurchaseConnector', '${IOS_PC_VER}'/" "$FILE" || true fi rm ${FILE}.bak || true echo "Updated podspec lines:" @@ -337,7 +375,7 @@ jobs: git config user.name "github-actions[bot]" if [[ -n $(git status -s) ]]; then git add pubspec.yaml android/ ios/ README.md || true - git commit -m "chore: prepare RC ${VERSION} (iOS ${{ needs.validate-release.outputs.ios_sdk_version }}, Android ${{ needs.validate-release.outputs.android_sdk_version }})" + git commit -m "chore: prepare RC ${VERSION} (iOS ${{ needs.validate-release.outputs.ios_sdk_version }} / PC ${{ needs.validate-release.outputs.ios_pc_version }}, Android ${{ needs.validate-release.outputs.android_sdk_version }})" git push --set-upstream origin "$REL_BRANCH" else echo "No changes to commit" diff --git a/.gitignore b/.gitignore index b6103e9f..20e10089 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,5 @@ coverage/ .af-e2e/reports/ .af-smoke/reports/ + +output.af-quiz-me/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 797b2237..944fa510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Updated iOS SDK from 6.17.9 to 6.18.0 - Updated iOS Purchase Connector from 6.17.9 to 6.18.0 - Fixed Android warm-app deep link consumption race so `DeepLinkListener` fires reliably when the app is resumed from a `VIEW` intent (forward new intents to `AppsFlyerLib` from the plugin's `onNewIntentListener` before the SDK's `onResume` auto-handler marks them `af_consumed`) +- Added Swift Package Manager (SPM) support for the Core iOS integration (`ios/appsflyer_sdk/Package.swift`), alongside continued full CocoaPods support β€” no behavior change for existing CocoaPods consumers. Purchase Connector remains CocoaPods-only for now, pending resolution of an upstream Flutter limitation ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)) that blocks conditionally-compiled plugin features under SPM. ## 6.17.9 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b2002a36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# AppsFlyer Flutter Plugin + +## Overview +Flutter plugin providing mobile attribution and analytics for iOS and Android. Bridges native AppsFlyer SDKs (iOS v6.17.9, Android v6.17.6) via Dart MethodChannel/EventChannel. Supports Flutter 2+ with null safety. + +## Starting a feature + +To start the full feature delivery workflow, use the slash command: + +``` +/af-ship <short description> +``` + +This invokes Alice, who writes a PRD, coordinates Bob and Erin if needed, and +manages Dave through tech design, implementation, and feature documentation. +Nothing else triggers the full workflow β€” all other requests go directly to the +relevant skill. + +## Direct invocation + +For everything outside of feature delivery, invoke skills directly: + +| Task | Invoke | +|------|--------| +| Code question, architecture, implementation | `dave-flutter-engineer` | +| Maintenance task (see list below) | `dave-flutter-engineer` | +| Platform API research, version behavior | `bob-flutter-researcher` | +| Payload analysis, field mapping, schema review | `erin-flutter-analyst` | + +## Architecture +- `lib/src/appsflyer_sdk.dart` β€” Main SDK class (singleton, MethodChannel/EventChannel bridge) +- `lib/src/callbacks.dart` β€” Attribution and event callback handlers +- `lib/src/udl/deeplink.dart` β€” Unified Deep Linking (UDL) implementation +- `lib/src/purchase_connector/` β€” In-app purchase validation models +- `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` β€” Android entry point +- `android/src/main/kotlin/` β€” Kotlin Purchase Connector for Android +- `ios/Classes/AppsflyerSdkPlugin.m` β€” iOS entry point (Objective-C) +- `ios/PurchaseConnector/` β€” iOS purchase validation module +- `test/` β€” Dart unit tests (mockito) +- `example/` β€” Full Flutter example app (iOS + Android) +- `doc/` β€” Per-feature integration guides + +## Commands +```bash +flutter pub get # Install dependencies +flutter test test # Run Dart unit tests +flutter pub run build_runner build # Regenerate JSON serialization code +``` + +## Coding Conventions +- **Linter**: `flutter_lints` with custom overrides in `analysis_options.yaml`. + - `public_member_api_docs` is disabled β€” no need to add dartdoc to every member. + - `constant_identifier_names` is disabled β€” follow existing naming in constants files. + - 80-char line limit is disabled β€” but keep lines readable. +- **JSON serialization**: Uses `json_annotation` + `json_serializable`. After changing annotated model classes, run `build_runner` to regenerate `.g.dart` files. Commit the generated files. +- **Testing**: `mockito` for mocking. Add tests in `test/` for new public API. +- Dart null safety is required β€” all new code must be null-safe. +- Keep `AppsflyerSdk` as a singleton; do not change the instantiation pattern. + +## Key Patterns +- New SDK method: add Dart method in `appsflyer_sdk.dart` (invoke via `_channel.invokeMethod`), implement in `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS). Keep method name strings consistent across all three files. +- Callbacks from native β†’ Dart flow through EventChannels defined in `callbacks.dart`. +- Deep linking (UDL) logic is isolated in `lib/src/udl/` β€” do not mix with core SDK channel calls. +- Purchase Connector is self-contained in `lib/src/purchase_connector/` (Dart models) and `ios/PurchaseConnector/` / `android/.../kotlin/` (native). + +## Testing +- Run `flutter test test` for the Dart unit test suite. +- Integration testing requires running the `example/` app on a device/emulator. +- CI uses Travis CI (`.travis.yml`) on Linux with Flutter stable. + +## Notes +- SDK version is set in `pubspec.yaml` and native dependency specs (podspec / `build.gradle`). +- Generated files (`*.g.dart`) must be committed β€” run `build_runner` after model changes. +- `doc/` and `example/` should be kept in sync with API changes. +- iOS native layer is Objective-C; Kotlin is used only for the Android Purchase Connector. + +## Maintenance bypass + +The following do not require a PRD or Alice review β€” invoke Dave directly: + +- Version bumps in `pubspec.yaml` and native dependency specs (`ios/*.podspec`, `android/build.gradle`) +- `CHANGELOG.md` updates +- Dependency bumps (`json_annotation`, `mockito`, `flutter_lints`, `build_runner`, etc.) +- Lint/formatting fixes +- Regenerating `*.g.dart` files via `build_runner` after model changes with no public API change +- Doc-only edits in `doc/` or `example/` +- Renames, dead-code removal, comment cleanup with no public API change + +## Output contract + +Every `/af-ship` deliverable must include: + +- Alice PRD (`internal-docs/prds/`) +- Bob findings (if invoked) +- Erin payload impact (if invoked) +- Dave tech design (`internal-docs/tech-designs/`) +- Dave implementation + unit tests +- Dave feature doc (`internal-docs/features/`) +- Alice sign-off at each phase diff --git a/README.md b/README.md index 08fbc49a..093afce8 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ To do so, please follow [this article](https://support.appsflyer.com/hc/en-us/ar ## SDK Versions -- Android AppsFlyer SDK **v6.18.0** -- iOS AppsFlyer SDK **v6.18.0** +- Android AppsFlyer SDK **v6.18.1** +- iOS AppsFlyer SDK **v6.18.1** ### Purchase Connector versions diff --git a/ai-delivery-workflow-templat/.gitignore b/ai-delivery-workflow-templat/.gitignore new file mode 100644 index 00000000..e43b0f98 --- /dev/null +++ b/ai-delivery-workflow-templat/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/ai-delivery-workflow-templat/README.md b/ai-delivery-workflow-templat/README.md new file mode 100644 index 00000000..1c11d6c5 --- /dev/null +++ b/ai-delivery-workflow-templat/README.md @@ -0,0 +1,247 @@ +# AI Delivery Workflow Template + +An AI-powered delivery workflow for engineering teams. Once set up, Claude Code acts as a team of specialized agents β€” a PM (Alice), an orchestrator, an engineer (Dave), a researcher (Bob), and a payload analyst (Erin) β€” that collaborate to take a feature from idea to shipped code with full documentation. + +--- + +## Step 1 β€” Copy the template into your repo + +Copy the `af-delivery-workflow-template/` folder to the **root** of your project repository: + +``` +your-repo/ +└── af-delivery-workflow-template/ ← copy this entire folder here +``` + +--- + +## Step 2 β€” Run the setup wizard in Claude Code + +Open **Claude Code** in your repo and paste the prompt below into the chat. The wizard will: + +1. Explore your codebase and confirm what it found (language, test commands, release process) +2. Check whether workflow files already exist +3. Generate all skill and command files, filled with your project's details +4. Delete the `af-delivery-workflow-template/` folder when done + +``` +I've copied the af-delivery-workflow-template/ folder into this repo. +The template files are: + + af-delivery-workflow-template/CLAUDE.md.template + af-delivery-workflow-template/WORKFLOW.md.template + af-delivery-workflow-template/commands/af-ship.md + af-delivery-workflow-template/commands/af-ship-from-prd.md + af-delivery-workflow-template/commands/af-ship-from-tech-design.md + af-delivery-workflow-template/commands/af-quiz-me.md + af-delivery-workflow-template/templates/af-tech-quiz-template.html + af-delivery-workflow-template/skills/af-ship-orch.template.md + af-delivery-workflow-template/skills/alice-pm.template.md + af-delivery-workflow-template/skills/dave-engineer.template.md + af-delivery-workflow-template/skills/bob-researcher.template.md + af-delivery-workflow-template/skills/erin-domain-analyst.template.md + af-delivery-workflow-template/prompts/generate-feature-catalog.template.md + af-delivery-workflow-template/prompts/generate-issue-cases.template.md + +Please set up the workflow for this repo by doing the following: + +**Step 1 β€” Explore the repo** +Read the codebase, existing docs, README, CI config, and any build files. +Determine: +- The full project/repo name +- The short domain name (e.g. ios, android, backend, frontend) +- The tech stack (languages, frameworks, build tools) +- How to run the test suite +- How releases are cut and published +- Where feature docs live (or suggest internal-docs/features/) +- Where research docs live (or suggest internal-docs/researches/) +- Where issue cases live (or suggest internal-docs/issue-cases/) +- What counts as a maintenance task (no Alice review needed) + +Present your findings and wait for my confirmation before continuing. + +**Step 2 β€” Check for existing workflow files** +Before writing anything, check whether these files already exist: + CLAUDE.md + .claude/WORKFLOW.md + .claude/commands/af-ship.md + .claude/commands/af-ship-from-prd.md + .claude/commands/af-ship-from-tech-design.md + .claude/commands/af-quiz-me.md + templates/af-tech-quiz-template.html + .claude/skills/*/SKILL.md + .claude/prompts/*.md + +Report what you find: +- List every file that already exists +- List every file that is new (does not exist yet) + +Wait for my confirmation before continuing. + +**Step 3 β€” Generate the new files** +Using the domain name from Step 1 and the filled-in placeholders, generate +all workflow files. Apply this rule for each target path: + +- If the file does NOT exist β†’ write it directly at the target path +- If the file ALREADY EXISTS β†’ write the new version alongside it with a + `.new` suffix (e.g. CLAUDE.md.new, SKILL.md.new) + +Target paths: + af-delivery-workflow-template/CLAUDE.md.template β†’ CLAUDE.md (or CLAUDE.md.new) + af-delivery-workflow-template/WORKFLOW.md.template β†’ .claude/WORKFLOW.md (or WORKFLOW.md.new) + af-delivery-workflow-template/commands/af-ship.md β†’ .claude/commands/af-ship.md (copy as-is, no placeholders) + af-delivery-workflow-template/commands/af-ship-from-prd.md β†’ .claude/commands/af-ship-from-prd.md (copy as-is) + af-delivery-workflow-template/commands/af-ship-from-tech-design.md β†’ .claude/commands/af-ship-from-tech-design.md (copy as-is) + af-delivery-workflow-template/commands/af-quiz-me.md β†’ .claude/commands/af-quiz-me.md (copy as-is) + af-delivery-workflow-template/templates/af-tech-quiz-template.html β†’ templates/af-tech-quiz-template.html (copy as-is) + af-delivery-workflow-template/skills/af-ship-orch.template.md β†’ .claude/skills/af-ship-orch/SKILL.md (or SKILL.md.new) + af-delivery-workflow-template/skills/alice-pm.template.md β†’ .claude/skills/alice-pm/SKILL.md (or SKILL.md.new) + af-delivery-workflow-template/skills/dave-engineer.template.md β†’ .claude/skills/dave-<domain>-engineer/SKILL.md (or SKILL.md.new) + af-delivery-workflow-template/skills/bob-researcher.template.md β†’ .claude/skills/bob-<domain>-researcher/SKILL.md (or SKILL.md.new) + af-delivery-workflow-template/skills/erin-domain-analyst.template.md β†’ .claude/skills/erin-<domain>-analyst/SKILL.md (or SKILL.md.new) + af-delivery-workflow-template/prompts/generate-feature-catalog.template.md β†’ .claude/prompts/generate-feature-catalog.md (or .md.new) + af-delivery-workflow-template/prompts/generate-issue-cases.template.md β†’ .claude/prompts/generate-issue-cases.md (or .md.new) + +Also update the `name:` frontmatter field in each SKILL.md to include the domain +(e.g. `name: dave-ios-engineer`). The af-ship-orch and alice-pm skill names do NOT +include the domain β€” copy them verbatim. + +Then delete the af-delivery-workflow-template/ folder. + +Add `output.af-quiz-me/` to the repo's `.gitignore` (the /af-quiz-me command writes generated quiz files there). + +**Step 4 β€” Fill in all {{PLACEHOLDER}} tokens** +Using the context from Step 1, replace every {{PLACEHOLDER}} in every generated file. + +The placeholders are: + {{REPO_NAME}} β€” full project name + {{DOMAIN}} β€” short domain name + {{TECH_STACK}} β€” languages, frameworks, build tools + {{TEST_COMMANDS}} β€” command(s) to run the test suite + {{RELEASE_PROCESS}} β€” how releases are cut and published + {{FEATURE_DOC_PREFIX}} β€” path to feature docs + {{RESEARCH_PATH}} β€” path to research docs + {{MAINTENANCE_TASKS}} β€” what counts as maintenance + {{ALICE_PROFILE_NOTES}} β€” domain-specific release/PRD notes for Alice + {{DAVE_PROFILE_NOTES}} β€” domain-specific engineering conventions for Dave + {{BOB_PROFILE_NOTES}} β€” domain-specific research sources for Bob + {{ERIN_PROFILE_NOTES}} β€” domain-specific payload/schema conventions for Erin + {{PROJECT_CONTEXT}} β€” one-sentence project description + {{LANGUAGES}} β€” primary language(s) + {{NOTION_DB_URL}} β€” Notion DB URL (leave blank if none) + {{NOTION_KEYWORDS}} β€” keywords to filter Notion pages (leave blank if none) + {{JIRA_PROJECT_KEY}} β€” Jira project key (leave blank β€” defaults to DELIVERY) + +**Step 5 β€” Show the delta for existing files** +For every file where a .new version was generated alongside an existing one, +show a diff between the old and the new: + + === CLAUDE.md === + --- existing + +++ new + [unified diff] + +For CLAUDE.md specifically, also check whether the existing file contains +these two required sections. Flag any that are missing: + + βœ…/❌ ## Maintenance bypass (required β€” Dave bypass list for maintenance tasks) + βœ…/❌ ## Output contract (required β€” defines what every feature deliverable must include) + +If any are missing, recommend appending them from CLAUDE.md.new rather than +doing a full replace, so existing repo-specific content is preserved. + +After showing all diffs and the CLAUDE.md section audit, ask: +"Which files should I replace, merge, or skip?" +Wait for my instructions before making any further changes. +``` + +--- + +## Step 3 β€” Build the feature catalog + +Open `.claude/prompts/generate-feature-catalog.md`, copy its full contents, and paste into Claude Code. + +This builds `internal-docs/features/` β€” a catalog of every feature in the codebase with: +- **Business Purpose** β€” what the product loses if the feature is removed (enriched from Notion if connected) +- **Call Chain** β€” from public entry point to leaf implementation +- **Dependency Diagrams** β€” runtime flow, initialization flow, and a full dependency table +- **Jira enrichment** β€” strategic "why" from Epics and Known Limitations from Bug tickets + +The prompt walks you through each phase with a live progress view and pauses for your review at key points. You will be asked for a Notion database URL and Jira project key during the run β€” have them ready if you want enriched Business Purpose sections. + +--- + +## Step 4 β€” Build the issue case bank + +Open `.claude/prompts/generate-issue-cases.md`, copy its full contents, and paste into Claude Code. + +This builds `internal-docs/issue-cases/` β€” an engineering scar book mined from the full git history: +- **IC-NNN-*.md** β€” one file per real bug: what happened, root cause, fix, and takeaway +- **GUARDRAILS.md** β€” engineering rules derived from past incidents, with a tech design checklist +- **Hot Zones Map** β€” which components carry the most historical risk +- **Pre-edit hook** β€” warns Claude Code before touching a historically fragile file + +--- + +## Step 5 β€” Ship features with commands + +Once the knowledge base is in place, use these commands in Claude Code for day-to-day delivery: + +| Command | When to use | +|---------|-------------| +| `/af-ship <description>` | New feature from scratch β€” Claude writes the PRD, challenges it, writes the tech design, implements, and produces a feature doc | +| `/af-ship --prd <url-or-path>` | You already have a PRD in Notion or as a local file | +| `/af-ship --tech-design <url-or-path>` | You already have a tech design β€” skips straight to implementation | +| `/af-quiz-me` | Generates a 10-question browser quiz from a tech design to verify the author or reviewer understands the feature | + +**Example:** +``` +/af-ship Add a retry mechanism to the event flush pipeline +``` + +The workflow pauses for your approval at: PRD β†’ tech design β†’ implementation β†’ feature doc. + +--- + +## What gets created + +After setup, your repo will have: + +``` +.claude/ +β”œβ”€β”€ WORKFLOW.md β€” skill communication map +β”œβ”€β”€ commands/ +β”‚ β”œβ”€β”€ af-ship.md β€” /af-ship entry point +β”‚ β”œβ”€β”€ af-ship-from-prd.md β€” /af-ship-from-prd entry point +β”‚ β”œβ”€β”€ af-ship-from-tech-design.md β€” /af-ship-from-tech-design entry point +β”‚ └── af-quiz-me.md β€” /af-quiz-me command +β”œβ”€β”€ skills/ +β”‚ β”œβ”€β”€ af-ship-orch/SKILL.md β€” workflow router +β”‚ β”œβ”€β”€ alice-pm/SKILL.md β€” PM agent (PRD, challenges) +β”‚ β”œβ”€β”€ dave-<domain>-engineer/SKILL.md β€” engineering agent (code, tech design) +β”‚ β”œβ”€β”€ bob-<domain>-researcher/SKILL.md β€” research agent (platform/API) +β”‚ └── erin-<domain>-analyst/SKILL.md β€” payload/schema agent +└── prompts/ + β”œβ”€β”€ generate-feature-catalog.md β€” feature catalog builder + └── generate-issue-cases.md β€” issue case bank builder +CLAUDE.md β€” project rules and agent contracts +templates/ +└── af-tech-quiz-template.html β€” quiz UI template +``` + +And after running the prompts: + +``` +docs/ +β”œβ”€β”€ features/ +β”‚ β”œβ”€β”€ INDEX.md β€” feature catalog index +β”‚ β”œβ”€β”€ DIAGRAM.md β€” runtime + initialization dependency diagrams +β”‚ └── F-NNN-*.md β€” one file per feature +β”œβ”€β”€ issue-cases/ +β”‚ β”œβ”€β”€ INDEX.md β€” issue case index + hot zones map +β”‚ β”œβ”€β”€ GUARDRAILS.md β€” engineering rules derived from past bugs +β”‚ └── IC-NNN-*.md β€” one file per issue case +β”œβ”€β”€ prds/ β€” PRDs written during /af-ship +β”œβ”€β”€ tech-designs/ β€” tech designs written during /af-ship +└── researches/ β€” research logs written by Bob +``` diff --git a/ai-delivery-workflow-templat/output.af-tech-quiz/af-tech-quiz-example.html b/ai-delivery-workflow-templat/output.af-tech-quiz/af-tech-quiz-example.html new file mode 100644 index 00000000..0509b02f --- /dev/null +++ b/ai-delivery-workflow-templat/output.af-tech-quiz/af-tech-quiz-example.html @@ -0,0 +1,305 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Tech Quiz Β· SharedPreferences Encryption + + + +
+ + + diff --git a/ai-delivery-workflow-templat/skills/.gitkeep b/ai-delivery-workflow-templat/skills/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ai-delivery-workflow-templat/update-existing-repo.md b/ai-delivery-workflow-templat/update-existing-repo.md new file mode 100644 index 00000000..5b5872a4 --- /dev/null +++ b/ai-delivery-workflow-templat/update-existing-repo.md @@ -0,0 +1,536 @@ +# Update Existing Repo β€” Workflow Skills Patch + +Paste this prompt into Claude Code in the repo that already has the skills applied. + +--- + +``` +The skill files in this repo need to be updated to match a newer version of the +workflow template. Please apply the following changes exactly. Find each section +by the text shown and replace it with the new content provided. + +--- + +## 1. .claude/skills/alice-feature-orchestrator/SKILL.md + +### Change 1 β€” description frontmatter +Find: + Also auto-invoked after Bob finishes research or Dave writes a tech design or code. + +Replace with: + Also auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc. + +--- + +### Change 2 β€” Orchestrator Mode steps (full section replacement) +Find this entire block: + **Step 2 β€” Write the delegation decision** + + > **Need Bob?** [yes/no] β€” Reason. Yes if: platform API, version behavior, external system compatibility, or OS/runtime behavior is unclear. + > **Need Erin?** [yes/no] β€” Reason. Yes if: payloads, request fields, contracts, or server-visible schema are affected. + > **Need Dave?** [yes/no] β€” Usually yes. No only for research-only or documentation-only work. + + **Step 3 β€” Invoke in order** + + - Bob needed β†’ call `Skill('bob--researcher')` immediately after the delegation block. + - Erin needed β†’ call `Skill('erin--analyst')` immediately. + - Both needed β†’ invoke Bob first if their domains are sequential; otherwise invoke concurrently. + - Dave β†’ call `Skill('dave--engineer')` **only after** Bob/Erin have completed and Alice has updated the PRD if findings changed scope. + + **Step 4 β€” Update PRD if scope changed** + + If Bob or Erin findings change Requirements, Acceptance criteria, or Risks β€” rewrite those sections before invoking Dave. + + **Step 5 β€” Challenge (Challenger Mode)** + + After each of Bob/Dave produces output, shift to Challenger Mode. This is not optional. + + **Step 6 β€” Close** + + Write `"Satisfied β€” [Person], this is ready."` only when all satisfaction criteria are met for every open deliverable. + +Replace with: + **Step 2 β€” Save PRD and ask user to review** + + Save the PRD to `internal-docs/prds/.md` where `` is a short kebab-case name (e.g. `device-farm-3d-header`). + + Then write: + Then write exactly: + + --- + ## ⏸ Waiting for your review + + PRD saved to `internal-docs/prds/.md`. + The workflow is paused. Reply **approved** to continue, or share your feedback and I'll update the PRD. + + --- + + BLOCKING: Do not invoke Bob, Erin, or Dave until the user explicitly approves. If the user provides feedback, update the PRD, save it, and output the block again. + + Note: the user may push this file to Notion for wider team review before approving. + + **Step 3 β€” Write the delegation decision** + + > **Need Bob?** [yes/no] β€” Reason. Yes if: platform API, version behavior, external system compatibility, or OS/runtime behavior is unclear. + > **Need Erin?** [yes/no] β€” Reason. Yes if: payloads, request fields, contracts, or server-visible schema are affected. + > **Need Dave?** [yes/no] β€” Usually yes. No only for research-only or documentation-only work. + + **Step 4 β€” Invoke in order** + + - Bob needed β†’ call `Skill('bob--researcher')` immediately after the delegation block. + - Erin needed β†’ call `Skill('erin--analyst')` immediately. + - Both needed β†’ invoke Bob first if their domains are sequential; otherwise invoke concurrently. + - Dave β†’ call `Skill('dave--engineer')` **only after** Bob/Erin have completed and Alice has updated the PRD if findings changed scope. + + **Step 5 β€” Update PRD if scope changed** + + If Bob or Erin findings change Requirements, Acceptance criteria, or Risks β€” rewrite those sections before invoking Dave. + + **Step 6 β€” Challenge (Challenger Mode)** + + After each of Bob/Dave produces output, shift to Challenger Mode. This is not optional. + + **Step 7 β€” Close** + + Write `"Satisfied β€” [Person], this is ready."` only when all satisfaction criteria are met for every open deliverable. + +Note: keep the actual skill names as they appear in this file (e.g. dave-ios-engineer, not the placeholder). + +--- + +### Change 3 β€” Loop Mechanics block +Find the entire code block inside Loop Mechanics (the ``` block) and replace it with: + + ``` + User presents feature idea + β†’ Alice writes PRD β†’ saves to internal-docs/prds/.md β†’ asks user to review + β†’ User approves PRD + β†’ Alice invokes Bob and/or Erin if needed + β†’ Bob/Erin produce findings + β†’ Alice challenges (Challenger Mode, max 2 iterations) + β†’ Bob/Erin address every open item + β†’ Alice updates PRD if scope changed + β†’ Alice invokes Dave + β†’ Dave writes tech design β†’ saves to internal-docs/tech-designs/.md + β†’ Alice challenges tech design (Challenger Mode, max 2 iterations) + β†’ Dave addresses every open item + β†’ Alice: "Satisfied β€” Dave, this is ready." (on tech design) + β†’ Dave asks user to review tech design + β†’ User approves tech design + β†’ Dave implements + writes unit tests + β†’ Alice challenges implementation (Challenger Mode, max 2 iterations) + β†’ Dave addresses every open item + β†’ Alice: "Satisfied β€” Dave, this is ready." (on implementation) + β†’ Dave writes F-NNN feature doc β†’ saves to internal-docs/features/ + β†’ Alice challenges feature doc (Challenger Mode, max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." (on feature doc) + β†’ If unresolved after 2 iterations β†’ Alice escalates to user + ``` + +--- + +### Change 4 β€” Challenge Agenda item 3 +Find: + ### 3. Feature Documentation + - Is the tech design written and linked? + - Post-code only: is the F-NNN doc written after Alice says "Satisfied"? + +Replace with: + ### 3. Feature Documentation + + **During tech design review:** + - Is the tech design saved to `internal-docs/tech-designs/.md`? + - Does the tech design cover all PRD requirements and acceptance criteria? + - Is the planned F-NNN ID noted in the design? + + **During feature doc review (Phase 3 only β€” do not check during tech design or implementation review):** + - Is the F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`? + - Does it follow `internal-docs/features/TEMPLATE.md`? + - Are Business Purpose, Call Chain, Files, and Tests sections complete? + +--- + +### Change 5 β€” Satisfaction criteria for Dave +Find: + ### Alice is satisfied with Dave when: + - [ ] GUARDRAILS context table was present before code + - [ ] Every touched hot-zone component has IC-NNN coverage stated + - [ ] Migration and rollout risk addressed β€” path documented or explicitly not required + - [ ] F-NNN doc complete or deferred with stated reason + - [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal + - [ ] No open challenge items without a response + +Replace with: + ### Alice is satisfied with Dave's tech design when: + - [ ] GUARDRAILS context table was present before the design + - [ ] Every affected hot-zone component has IC-NNN coverage stated + - [ ] Migration and rollout risk addressed β€” path documented or explicitly not required + - [ ] Planned F-NNN ID noted in the design + - [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal + - [ ] No open challenge items without a response + + ### Alice is satisfied with Dave's implementation when: + - [ ] GUARDRAILS context table was present before the code + - [ ] Every touched hot-zone component has IC-NNN coverage stated + - [ ] Unit tests cover happy path and key edge cases + - [ ] Test suite passes + - [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal + - [ ] No open challenge items without a response + + ### Alice is satisfied with Dave's feature doc when: + - [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md` + - [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests) + - [ ] No open challenge items without a response + +--- + +### Change 6 β€” Docs Locations section +Find: + ## Feature Docs Location + + PRDs and feature docs land in ``. + +Replace with: + ## Docs Locations + + - PRDs β†’ `internal-docs/prds/.md` (temporary β€” user may push to Notion for review) + - Feature catalog docs β†’ `` (permanent) + +--- + +## 2. .claude/skills/dave--engineer/SKILL.md + +### Change 1 β€” Replace tech design and post-code sections +Find this entire block: + ### During tech design β€” location choice + + Ask the user before writing: + + > "Where should I write this tech design? + > 1. **Notion** β€” tech design board + > 2. **Local file** β€” `internal-docs/tech-designs/.md`" + + Do NOT write tech designs in `internal-docs/features/` β€” that directory is for finished feature catalog docs only. + Note the planned F-NNN ID in the design as "F-NNN β€” doc to be written after development is complete." + + ### After completing a code change + + 5. Find which feature docs reference each changed file: + ``` + grep "ChangedFile" internal-docs/features/INDEX.md + ``` + 6. Update any section whose behavior, public API, configuration, or data flow changed. + 7. If the change introduces a new feature: write the full F-NNN doc and add it to `internal-docs/features/INDEX.md` **only after Alice has written "Satisfied β€” Dave, this is ready."** Not before. + +Replace with: + ### Phase 1 β€” Tech design + + Write the tech design to `internal-docs/tech-designs/.md` where `` is the same kebab-case slug used for the PRD (e.g. `device-farm-3d-header`). + + Do NOT write tech designs in `internal-docs/features/` β€” that directory is for finished feature catalog docs only. + Note the planned F-NNN ID in the design as "F-NNN β€” doc to be written after development is complete." + + After writing the tech design, call `Skill('')` immediately for review. + + When Alice writes "Satisfied β€” Dave, this is ready." on the tech design, write: + When Alice writes "Satisfied β€” Dave, this is ready." on the tech design, write exactly: + + --- + ## ⏸ Waiting for your review + + Tech design saved to `internal-docs/tech-designs/.md`. Alice has signed off. + The workflow is paused. Reply **approved** to start implementation, or share your feedback. + + --- + + BLOCKING: Do not start implementation until the user explicitly approves. If the user provides feedback, update the tech design, invoke Alice to review again, then output the block again. + + Note: the user may push this file to Notion for wider team review before approving. + + ### Phase 2 β€” Implementation + + After user approves the tech design: + - Implement the feature according to the PRD and tech design. + - Write unit tests covering the happy path and key edge cases. + - Run the test suite (see Test commands reference below). + - Call `Skill('')` for implementation review. + + ### Phase 3 β€” Feature doc + + After Alice writes "Satisfied β€” Dave, this is ready." on the implementation: + + **Step 1 β€” Impact scan (do this before writing anything)** + + For every file changed during implementation, run: + ``` + grep "" internal-docs/features/INDEX.md + ``` + Run once per changed file. Then print this table: + + | Changed file | Affected F-NNN docs | + |---|---| + | `path/to/file` | F-NNN, F-NNN or "none" | + + For every affected F-NNN doc found: open it and update every section whose behavior, public API, configuration, or data flow changed. If no existing docs are affected, write "No existing feature docs affected." + + **Step 2 β€” Write the new feature doc** + + Write the full F-NNN feature catalog doc to `internal-docs/features/.md` and add it to `internal-docs/features/INDEX.md`. + + **Step 3 β€” Call Alice** + + Call `Skill('')` to review. This is a separate Alice review loop focused only on feature docs β€” not the code. + +Note: replace `` with the actual skill name used in this file (e.g. `alice-feature-orchestrator`). + +--- + +### Change 2 β€” Test commands section header and note +Find: + ### Test commands + + ``` + + ``` + +Replace with: + ### Test commands reference + + ``` + + ``` + + Run after every implementation change (Phase 2) before calling Alice. + +--- + +### Change 3 β€” Alice Review Loop trigger +Find: + After producing ANY code or tech design output, call `Skill('alice-feature-orchestrator')` immediately. + +Replace with: + After producing ANY code, tech design, or feature doc output, call `Skill('alice-feature-orchestrator')` immediately. + +(Use the actual alice skill name as it appears in the file.) + +--- + +## 3. .claude/skills/bob--researcher/SKILL.md + +### Change 1 β€” Remove line from Alice Review Loop section +Find and delete this line (it appears just after the sentence about the loop closing): + If Alice ends her output with "Bob β€”", Bob must respond in the next turn. + +--- + +## 4. .claude/skills/erin--analyst/SKILL.md + +### Change 1 β€” Context field in analysis doc format +Find: + What triggered this analysis β€” customer report, QA finding, CI diff, etc. + +Replace with: + What triggered this analysis β€” PRD requirement for [feature], customer report, QA finding, CI diff, etc. + +--- + +--- + +## 5. .claude/WORKFLOW.md + +### Change 1 β€” Mermaid diagram entry arrow +Find: + User -->|"feature / PRD / impl request"| Alice + +Replace with: + User -->|"/af-ship"| Alice + +--- + +### Change 2 β€” Docs Layer table (full replacement) +Find: + | `` | Feature catalog | Dave (writes F-NNN) | Alice, Bob, Erin (read) | + | `internal-docs/issue-cases/` | Scar book | Human / eng team | Alice, Dave, Bob, Erin (read) | + | `` | Research log | Bob (writes R-NNN) | Alice (via challenge loop) | + | `docs/payloads/` | Payload map | Erin (writes P-NNN, FIELD_MAP) | Alice, Dave (via challenge loop) | + +Replace with: + | `internal-docs/prds/` | PRDs (staging) | Alice (writes) | User review; may move to Notion | + | `internal-docs/tech-designs/` | Tech designs (staging) | Dave (writes) | User review; may move to Notion | + | `` | Feature catalog | Dave (writes F-NNN) | Alice, Bob, Erin (read) | + | `internal-docs/issue-cases/` | Scar book | Human / eng team | Alice, Dave, Bob, Erin (read) | + | `` | Research log | Bob (writes R-NNN) | Alice (via challenge loop) | + | `docs/payloads/` | Payload map | Erin (writes P-NNN, FIELD_MAP) | Alice, Dave (via challenge loop) | + +Note: preserve the actual paths already in the file for feature docs and research. + +--- + +### Change 3 β€” Invocation Rules table (full replacement) +Find the entire Invocation Rules table block (from `| Entry point |` through the "If unsure" line) and replace with: + + | Entry point | When | + |-------------|------| + | `/af-ship ` β†’ Alice | Starting any feature β€” PRD, tech design, implementation, feature doc | + | Dave (direct) | Maintenance only: logs, renames, dead-code removal, comment cleanup, test additions, minor refactors with no public API change | + | Bob (direct) | Ad-hoc platform/API research not tied to a feature | + | Erin (direct) | Ad-hoc payload or schema analysis not tied to a feature | + | Bob | Invoked by Alice when platform API / version / external behavior is unclear | + | Erin | Invoked by Alice when payloads, request fields, or server-visible schema is affected | + + If unsure whether a task is maintenance or a feature β†’ use `/af-ship`. + +--- + +### Change 4 β€” Loop Mechanics block (full replacement) +Find the entire code block inside Loop Mechanics (the ``` block) and replace with: + + ``` + User runs /af-ship + β†’ Alice writes PRD β†’ saves to internal-docs/prds/.md β†’ asks user to review + β†’ User approves PRD + β†’ Alice invokes Bob and/or Erin if needed + β†’ Bob/Erin produce findings + β†’ Alice challenges (max 2 iterations) + β†’ Alice updates PRD if scope changed + β†’ Alice invokes Dave + + Phase 1 β€” Tech design + β†’ Dave writes tech design β†’ saves to internal-docs/tech-designs/.md + β†’ Alice challenges tech design (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." (on tech design) + β†’ Dave asks user to review tech design + β†’ User approves tech design + + Phase 2 β€” Implementation + β†’ Dave implements + writes unit tests + β†’ Alice challenges implementation (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." (on implementation) + + Phase 3 β€” Feature doc + β†’ Dave writes F-NNN feature doc β†’ saves to internal-docs/features/ + β†’ Alice challenges feature doc (max 2 iterations) + β†’ Alice: "Satisfied β€” Dave, this is ready." (on feature doc) + ``` + +--- + +## 6. .claude/commands/af-ship.md (NEW FILE β€” create if it does not exist) + +Create this file at `.claude/commands/af-ship.md` with the following content exactly: + + Start the full feature delivery workflow for the following feature: + + $ARGUMENTS + + Invoke the `alice-feature-orchestrator` skill now to begin. Alice will write a PRD, + save it to internal-docs/prds/, ask for your review, then coordinate research and engineering + through tech design, implementation, and feature documentation. + +--- + +## 7. CLAUDE.md + +### Change 1 β€” Replace entire file content +Replace the full contents of CLAUDE.md with: + + # AI Workflow + + ## Starting a feature + + To start the full feature delivery workflow, use the slash command: + + ``` + /af-ship + ``` + + This invokes Alice, who writes a PRD, coordinates Bob and Erin if needed, and + manages Dave through tech design, implementation, and feature documentation. + Nothing else triggers the full workflow β€” all other requests go directly to the + relevant skill. + + ## Direct invocation + + For everything outside of feature delivery, invoke skills directly: + + | Task | Invoke | + |------|--------| + | Code question, architecture, implementation | `dave--engineer` | + | Maintenance task (see list below) | `dave--engineer` | + | Platform API research, version behavior | `bob--researcher` | + | Payload analysis, field mapping, schema review | `erin--analyst` | + + ## Maintenance tasks + + The following do not require a PRD or Alice review β€” invoke Dave directly: + + + + ## Output contract + + Every `/af-ship` deliverable must include: + + - Alice PRD (`internal-docs/prds/`) + - Bob findings (if invoked) + - Erin payload impact (if invoked) + - Dave tech design (`internal-docs/tech-designs/`) + - Dave implementation + unit tests + - Dave feature doc (`internal-docs/features/`) + - Alice sign-off at each phase + +Note: preserve the existing maintenance tasks list and skill names (domain-specific). + +--- + +## 8. .claude/skills/alice-feature-orchestrator/SKILL.md (continued) + +### Change 3 β€” description frontmatter (full replacement) +Find: + description: Use for ANY feature idea, PRD request, product request, or implementation request β€” Alice is PM owner, orchestrator, and release gate and runs first. Also auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc. Alice challenges both on completeness, version coverage, compliance risk, GUARDRAILS compliance, and business impact. + +Replace with: + description: Auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc β€” challenge and close the loop. Also invoked by the /af-ship command to start a new feature. Do NOT invoke for questions, ad-hoc analysis, research, or maintenance tasks. + +--- + +### Change 4 β€” Orchestrator Mode trigger line +Find: + **Trigger:** User presents a feature idea, vague request, PRD request, or implementation request. + +Replace with: + **Trigger:** User runs `/af-ship `. + +--- + +### Change 5 β€” Auto-invocation rule trigger +Find: + **When user presents a feature idea, PRD request, product request, or implementation request:** + BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-feature-orchestrator` BEFORE any other response. Do not write code, investigate the codebase, or ask clarifying questions before Alice has produced a PRD. + +Replace with: + **When `/af-ship` command is run:** + BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-feature-orchestrator` BEFORE any other response. Do not write code, investigate the codebase, or ask clarifying questions before Alice has produced a PRD. + +--- + +--- + +### Change 6 β€” Alice satisfaction criteria for feature doc +Find: + ### Alice is satisfied with Dave's feature doc when: + - [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md` + - [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests) + - [ ] No open challenge items without a response + +Replace with: + ### Alice is satisfied with Dave's feature doc when: + - [ ] Impact scan table was printed β€” every changed file checked against `internal-docs/features/INDEX.md` + - [ ] All affected existing F-NNN docs updated, or "none affected" explicitly stated + - [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md` + - [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests) + - [ ] No open challenge items without a response + +--- + +After applying all changes, confirm which files were updated. +``` diff --git a/android/build.gradle b/android/build.gradle index 8ce06bc6..c384b4ae 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -53,7 +53,7 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.0.0' - implementation 'com.appsflyer:af-android-sdk:6.18.0' + implementation 'com.appsflyer:af-android-sdk:6.18.1' implementation 'com.android.installreferrer:installreferrer:2.2' // implementation 'androidx.core:core-ktx:1.13.1' if (includeConnector) { diff --git a/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java b/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java index 04d51e99..3471f398 100644 --- a/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java +++ b/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java @@ -1,7 +1,7 @@ package com.appsflyer.appsflyersdk; public final class AppsFlyerConstants { - final static String PLUGIN_VERSION = "6.18.0"; + final static String PLUGIN_VERSION = "6.18.1-rc1"; final static String AF_APP_INVITE_ONE_LINK = "appInviteOneLink"; final static String AF_HOST_PREFIX = "hostPrefix"; final static String AF_HOST_NAME = "hostName"; diff --git a/doc/Installation.md b/doc/Installation.md index fe8be440..289fab96 100644 --- a/doc/Installation.md +++ b/doc/Installation.md @@ -10,6 +10,13 @@ flutter pub add appsflyer_sdk This will download the AppsFlyer flutter plugin to your project, you may observe the changes in your `pubspec.yaml` file. +--- +## iOS: Swift Package Manager (SPM) support + +Starting with v6.18.0, the plugin's **Core** integration supports Swift Package Manager on iOS, alongside continued full CocoaPods support. If your app has SPM enabled (the default on Flutter 3.44+, or via `flutter config --enable-swift-package-manager` on Flutter 3.24+), no extra setup is needed β€” Flutter's tooling picks up the plugin's `Package.swift` automatically. + +**If you use Purchase Connector, do not enable SPM for this plugin.** [Purchase Connector](PurchaseConnector.md) requires CocoaPods for the entire plugin (Core included) β€” it cannot currently be combined with SPM, pending resolution of an upstream Flutter limitation ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). SPM is recommended only for apps that don't use Purchase Connector at all; if you don't, keep CocoaPods and the `$AppsFlyerPurchaseConnector` Podfile flag as documented in [PurchaseConnector.md](PurchaseConnector.md). + --- ## Huawei Referrer Huawei Referrer is supported in SDK v6.14.0 and above. diff --git a/doc/PurchaseConnector.md b/doc/PurchaseConnector.md index 6c63c52f..9cab7572 100644 --- a/doc/PurchaseConnector.md +++ b/doc/PurchaseConnector.md @@ -81,6 +81,8 @@ appsflyer.enable_purchase_connector=true ``` Once you set these properties, the Purchase Validation feature will be integrated into your project and you can utilize its functionality in your app. +> ⚠️ **iOS + Swift Package Manager**: Purchase Connector requires **CocoaPods for the entire plugin** β€” there is no Swift Package Manager path for it, and it cannot currently be combined with Swift Package Manager for the Core integration either. This is a temporary limitation pending an upstream Flutter fix ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). **If your app uses Purchase Connector, do not enable Swift Package Manager for this plugin β€” keep your `Podfile` and use CocoaPods for both Core and Purchase Connector.** If you enable SPM anyway, calling any Purchase Connector API will silently fail with a `MissingPluginException` β€” see the next section. SPM is only recommended for apps that don't use Purchase Connector at all (see [Installation.md](Installation.md#ios-swift-package-manager-spm-support)). + ### What Happens if You Use Dart Files Without Opting In? The Dart files for the Purchase Validation feature are always included in the plugin. If you try to use these Dart APIs without opting into the feature, the APIs will not have effect because the corresponding native code necessary for them to function will not be included in your project. diff --git a/docs/RELEASE_USER_MANUAL.md b/docs/RELEASE_USER_MANUAL.md deleted file mode 100644 index 09c837ca..00000000 --- a/docs/RELEASE_USER_MANUAL.md +++ /dev/null @@ -1,141 +0,0 @@ -# Release user manual (AppsFlyer Flutter plugin) - -One-page operator guide for cutting and shipping a release candidate. Read this end-to-end the first time; after that you'll only need steps 1 and 4-6. - -For contract meaning and stage IDs, see [`appsflyer-mobile-plugin-tooling/contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md). For the file-layout rationale and local dry-run, see [`rc-pipeline-poc.md`](./rc-pipeline-poc.md). - -## Prerequisites - -- Write access to `AppsFlyerSDK/appsflyer-flutter-plugin` on GitHub. -- Flutter SDK installed locally (used for the optional local simulation). -- `appsflyer-mobile-plugin-tooling` checked out next to this repo if you plan to cross-reference contracts. -- Repo secrets in place: `ENV_FILE`, `PUB_DEV_CREDENTIALS`, `CI_SLACK_WEBHOOK_URL`, `CI_JIRA_EMAIL`, `CI_JIRA_TOKEN`, `CI_JIRA_DOMAIN`. Ask an admin if any are missing; `ENV_FILE` must contain a valid `DEV_KEY` + `APP_ID` that launches cleanly on both platforms. - -## Step 1 - Trigger the RC workflow - -1. Open the Actions tab β†’ **RC - Release Candidate** workflow β†’ **Run workflow**. -2. Fill in the inputs: - - | Input | Example | Notes | - |-------|---------|-------| - | `base_branch` | `development` | Default. Override only when cutting a hotfix off another branch (e.g. `master`-derived patch). | - | `flutter_version` | `6.18.0-rc1` | Must match `^\d+\.\d+\.\d+(\+\d+)?-rc\d+$` | - | `ios_sdk_version` | `6.17.7` | Native wrapper version | - | `android_sdk_version` | `6.17.4` | Native wrapper version | - | `skip_unit` | `false` | Skips the lint/format/unit-test job inside Lint, Test & Build. Release builds still run. Does **not** block publish. | - | `skip_e2e` | `false` | Skips RC-E2E iOS + Android. **Blocks publish-rc** (E2E success is the publish gate). Use only for iterative work on the rest of the pipeline. | - | `dry_run` | `false` | Leave `true` for drills; set `false` for a real RC | - -3. Click **Run workflow**. The workflow runs `validate-release`, then `prepare-branch` and `run-ci` (Lint, Test & Build) in parallel; once `prepare-branch` finishes, `run-e2e-ios` and `run-e2e-android` start in parallel too. `publish-rc` waits only on E2E success β€” Lint, Test & Build is informational, not a publish gate. - -## Step 2 - Wait for the automated gates - -Four checks must go green before you do anything: - -| Check | Workflow | Notes | -|-------|----------|-------| -| `Lint, Test & Build` | `lint-test-build.yml` (via `rc-release.yml`) | Lint + format + unit tests + release-mode Android/iOS builds | -| `iOS E2E` | `ios-e2e.yml` | RC-E2E iOS gate | -| `Android E2E` | `android-e2e.yml` | RC-E2E Android gate | -| `rc-smoke/pub.dev` | `rc-smoke.yml` | Only appears after `publish-rc` succeeds with `dry_run=false` | - -- If any E2E gate fails, fix the code on the release branch and push. E2E re-runs automatically. -- If publish fails on a version collision, bump to `rcN+1` and rerun Step 1 with the new version. -- If `rc-smoke/pub.dev` is red, the RC is broken on pub.dev. Bump to `rcN+1`. -- If `rc-smoke/pub.dev` is `skipped`, the parent run was a dry run or the RC isn't on pub.dev yet. See Troubleshooting β†’ "`rc-smoke/pub.dev` is skipped" before applying the promote label; promotion will reject `skipped` as a green gate. - -## Step 3 - Review the auto-opened PR - -`rc-release.yml` opens a PR from the release branch to `master` automatically after `publish-rc`. Review: - -- Version bumps in `pubspec.yaml`, `android/build.gradle`, `ios/appsflyer_sdk.podspec`, native constants, `README.md`. -- `CHANGELOG.md` β€” add the new version section if it isn't there yet. -- All four checks green on the PR head SHA. - -Slack gets a ping from `notify-team` with the RC link and the Jira tickets pinned to `Flutter SDK v`. - -## Step 4 - Apply the promote label - -When everything is green and the diff looks right, apply the label **`pass QA ready for deploy`** to the PR. - -This triggers `promote-release.yml`, which: - -1. Verifies `rc-smoke/pub.dev` is `success` on the PR head SHA. A missing, in-progress, or `skipped` check-run fails this step with a PR comment; fix and re-apply the label. -2. Strips `-rcN` from `pubspec.yaml`, native version constants. -3. Commits and pushes to the release branch. -4. Updates the PR description to say "Ready for manual merge." - -If the label triggers a failure, read the bot comment; it points at the exact reason. - -## Step 5 - Merge the PR - -Merge the PR manually. Org branch protection prevents bot merges. This is the single human gate for the entire pipeline. - -## Step 6 - Confirm production publish - -`production-release.yml` fires on the `master` merge commit: - -- Publishes `appsflyer_sdk:^X.Y.Z` to pub.dev. -- Creates GitHub release `vX.Y.Z` with release notes from `CHANGELOG.md`. -- Sends a Slack release notification. - -Verify at (may take a few minutes to index). - -## Troubleshooting - -### `iOS E2E` or `Android E2E` is red - -1. Open the failing workflow run (`ios-e2e.yml` or `android-e2e.yml`) and download the `ios-e2e-` or `android-e2e-` artifact. -2. Open the JSON report under `.af-e2e/reports/`; find the first `"status": "FAIL"` check; read its `evidence`. -3. Cross-reference [`appsflyer-mobile-plugin-tooling/docs/troubleshooting.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/docs/troubleshooting.md) for boot timeouts, adb flakes, simctl issues. -4. Fix the plugin source on the release branch, push. E2E re-runs automatically. - -### `rc-smoke/pub.dev` is red - -1. Open the `rc-smoke.yml` run; download `rc-smoke-ios-` or `rc-smoke-android-` artifacts. -2. Check the JSON report for failing checks. -3. Typical causes: the pub.dev RC has a genuine defect (bump to `rcN+1`), or a test-app regression shared with E2E (fix and bump). -4. After fixing, rerun Step 1 with `rc-release.yml` and the next `rcN`. - -### `rc-smoke/pub.dev` is skipped - -- Parent run was `dry_run=true`: re-run Step 1 with `dry_run=false`. -- RC isn't indexed on pub.dev yet: wait ~5 minutes and re-run `rc-smoke.yml` manually from the Actions tab with the RC version + release branch as inputs. - -### Bumping to `rcN+1` - -pub.dev does not allow republishing the same version. If smoke or review catches a defect: - -1. Rerun Step 1 with `flutter_version=X.Y.Z-rcN+1` (increment the rc number, keep everything else the same). -2. The existing release branch gets the new version bump on top; old RC stays on pub.dev but is superseded. - -### Rerunning smoke only - -If you need to re-smoke an already-published RC (e.g. flake), dispatch `rc-smoke.yml` manually with: - -- `rc_version`: the exact pub.dev RC version string. -- `release_branch`: the release branch the smoke check should be associated with. - -A fresh `rc-smoke/pub.dev` check-run will post on the latest commit of that branch. - -### Rolling back a bad production release - -Out of scope for this manual. Coordinate with engineering leadership and the on-call. The publish is immutable on pub.dev, so a rollback is "bump the next patch release with a revert commit." - -## Dry-run drill path - -To exercise the pipeline without touching a real version: - -1. Step 1 inputs: `flutter_version=99.99.99-rc1`, `ios_sdk_version=6.17.7`, `android_sdk_version=6.17.4`, `dry_run=true`. -2. Confirm E2E runs, `publish-rc` skips the pub.dev call, PR opens, prerelease tag `99.99.99-rc1` is created on GitHub, `rc-smoke/pub.dev` posts `skipped`. -3. Clean up: delete the release branch, the prerelease tag, and the scratch PR. - -For a full local simulation with no CI, run `./scripts/simulate-rc-pipeline.sh --platform ios` on your workstation. - -## Reference - -- Stage IDs and pass criteria: [`appsflyer-mobile-plugin-tooling/contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md) -- Pre-publish E2E meaning: [`appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/e2e-test-contract.md) -- Post-publish smoke meaning: [`appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/smoke-test-contract.md) -- Test app behavior: [`appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/test-app-contract.md) -- Local simulation + CI dry-run acceptance test: [`rc-pipeline-poc.md`](./rc-pipeline-poc.md) diff --git a/docs/rc-pipeline-poc.md b/docs/rc-pipeline-poc.md deleted file mode 100644 index 4d7180d5..00000000 --- a/docs/rc-pipeline-poc.md +++ /dev/null @@ -1,81 +0,0 @@ -# RC pipeline POC - -This doc describes how to prove the unified RC pipeline works end-to-end before we trust it for a real release. Two levels: local simulation (no remote calls) and CI dry-run against a throwaway version. - -For the user-facing operator manual, see [`RELEASE_USER_MANUAL.md`](./RELEASE_USER_MANUAL.md) (added in PR D). For contract meaning and stage definitions, see [`appsflyer-mobile-plugin-tooling/contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md). - -## Why a POC - -The pipeline is additive; it does not replace a working release process. We need a way to exercise every stage without risking a real pub.dev artifact. Two layers cover that. - -## Level 1: local simulation - -Run on your workstation. No remote calls. Proves the plans, runner, scripts, and example app scaffolding all line up. - -```sh -./scripts/simulate-rc-pipeline.sh --platform ios -# or --platform android -# or --platform both -``` - -What the script does: - -1. Cuts a throwaway branch `releases/poc/99.99.99-rc1-poc`. -2. Stamps `pubspec.yaml` with `99.99.99-rc1-poc` (the RC-PREP stage in the contract). -3. Runs `.af-e2e/test-plan.json` against `example/` via `scripts/af-scenario-runner.sh` (the RC-E2E stage). -4. Simulates RC-PUBLISH β€” nothing real happens here, it prints a confirmation. -5. Runs `.af-smoke/rc-test-plan.json` against `example/` using a runtime-rewritten plan that substitutes `path: ..` for the pub.dev pin (the RC-SMOKE stage, minus the registry dependency). -6. Restores `pubspec.yaml` and deletes the throwaway branch. - -Exit code 0 means both E2E and smoke passed. Exit code 1 means one of them failed; the script leaves a passing combined summary off. - -Pass `--keep-branch` if you want to inspect the staging state after the run. - -## Level 2: CI dry-run - -Once the four plugin PRs (A, B, C, D) land, exercise the full workflow tree against a scratch version. Inputs: - -- `flutter_version=99.99.99-rc1` -- `ios_sdk_version=6.17.7` (or whatever today's native wrappers pin) -- `android_sdk_version=6.17.4` -- `dry_run=true` - -Expected behavior with `dry_run=true`: - -- `rc-release.yml` creates the branch, applies version bumps, runs CI, and runs the two E2E workflows via `workflow_call`. -- `publish-rc` job runs validation but skips the real `flutter pub publish`. -- `open-pr` opens the PR to `master`. -- `create-prerelease` cuts the `99.99.99-rc1` tag as a GitHub prerelease. -- `notify-team` posts a Slack ping (or logs a skip if the webhook is unreachable). -- `rc-smoke.yml` fires via `workflow_run` on completion, detects the `dry_run=true` signal, posts `rc-smoke/pub.dev` check-run with conclusion `skipped`, and exits fast. - -Expected behavior with `dry_run=false` on the same scratch version (run after the dry run): - -- Everything above, plus `publish-rc` actually publishes `99.99.99-rc1` to pub.dev. -- `rc-smoke.yml` templates `example_rc_smoke/pubspec.yaml` with `appsflyer_sdk: =99.99.99-rc1`, builds, runs `SMOKE-001/002/003` on both platforms, uploads reports, and posts `rc-smoke/pub.dev` with conclusion `success`. -- The PR shows all four checks green: `CI`, `iOS E2E`, `Android E2E`, `rc-smoke/pub.dev`. - -Negative promote test (proves the gate): - -1. Take an old merged PR or a fresh dummy PR without a green `rc-smoke/pub.dev` check. -2. Apply the `pass QA ready for deploy` label. -3. `promote-release.yml` should fail fast with a PR comment pointing at the missing check-run. - -Positive promote test (full loop): - -1. On the real `99.99.99-rc1` PR, apply the `pass QA ready for deploy` label. -2. `promote-release.yml` verifies the green smoke check, strips `-rc1` from the release branch, pushes, and updates the PR description. -3. Merge the PR manually. -4. `production-release.yml` publishes `99.99.99` to pub.dev and cuts the GitHub release. -5. Clean up: delete the `99.99.99-rc1` tag and the `99.99.99` tag from the scratch version so the version slot is free again. - -## POC acceptance checklist - -- [ ] `simulate-rc-pipeline.sh --platform both` exits zero on a dev workstation with an emulator + simulator booted. -- [ ] Dry-run CI run on `99.99.99-rc1` produces a PR with `rc-smoke/pub.dev=skipped` and no pub.dev artifact. -- [ ] Wet-run CI run on `99.99.99-rc1` produces a PR with `rc-smoke/pub.dev=success` and a real pub.dev artifact. -- [ ] Negative promote (mis-applied label) leaves a PR comment and does not strip `-rc1`. -- [ ] Positive promote strips `-rc1`, merge publishes `99.99.99` to pub.dev, GitHub release exists. -- [ ] All four workflows (`rc-release.yml`, `rc-smoke.yml`, `promote-release.yml`, `production-release.yml`) appear in the Actions tab with the expected job names. - -Mark each of these as you go in the PR description of the PR that lands this doc; those are the acceptance criteria. diff --git a/example_rc_smoke/README.md b/example_rc_smoke/README.md index 430ac3b1..74ed8386 100644 --- a/example_rc_smoke/README.md +++ b/example_rc_smoke/README.md @@ -32,4 +32,4 @@ It rewrites the smoke plan at runtime to point at `example/` with a `path: ..` d - `../scripts/af-scenario-runner.sh` β€” the runner `rc-smoke.yml` calls. - `../.af-smoke/rc-test-plan.json` β€” the test plan; `build_cmd` targets `example_rc_smoke/`. - `../.github/workflows/rc-smoke.yml` β€” the workflow that templates this `pubspec.yaml` and runs smoke. -- `../docs/RELEASE_USER_MANUAL.md` β€” operator manual. +- `../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md` β€” operator manual (lives in the sibling tooling repo). diff --git a/internal-docs/features/DIAGRAM.md b/internal-docs/features/DIAGRAM.md new file mode 100644 index 00000000..952f7924 --- /dev/null +++ b/internal-docs/features/DIAGRAM.md @@ -0,0 +1,169 @@ +# AppsFlyer Flutter Plugin β€” Feature Diagrams + +## Section 1 β€” Runtime Flow + +Only features with at least one inbound or outbound cross-feature edge are shown. `eventsAndRevenue` and `platformIntegration` have no cross-feature edges in this codebase β€” every feature in those two categories is a standalone 1:1 native setter, so neither appears below. + +```mermaid +flowchart TD + subgraph sdkCore ["sdkCore"] + F001["F-001
SDK Initialization"]:::sdkCore + F002["F-002
SDK Start"]:::sdkCore + F011["F-011
TCF/DMA Auto Consent"]:::sdkCore + F015["F-015
Customer User ID"]:::sdkCore + F021["F-021
Delayed Session Start"]:::sdkCore + F034["F-034
Ad ID Collection Disable"]:::sdkCore + F048["F-048
Plugin Metadata Reporting"]:::sdkCore + F057["F-057
ASA Opt-out"]:::sdkCore + F058["F-058
ATT Wait Timeout"]:::sdkCore + F059["F-059
Debug Logging Toggle"]:::sdkCore + end + + subgraph purchaseValidation ["purchaseValidation"] + F023["F-023
IAP Validation V1"]:::purchaseValidation + F024["F-024
IAP Validation V2"]:::purchaseValidation + F025["F-025
Receipt Sandbox Toggle"]:::purchaseValidation + F038["F-038
Legacy Validation Callback"]:::purchaseValidation + F049["F-049
Purchase Connector Config"]:::purchaseValidation + F050["F-050
StoreKit Version Selection"]:::purchaseValidation + F051["F-051
Android Validation Listeners"]:::purchaseValidation + F052["F-052
iOS Combined Validation Callback"]:::purchaseValidation + F053["F-053
Google Play Data Models"]:::purchaseValidation + F054["F-054
Build-Time Opt-in"]:::purchaseValidation + F055["F-055
Missing-Config Guard"]:::purchaseValidation + end + + subgraph deepLinking ["deepLinking"] + F014["F-014
Manual Deep-Link Re-trigger"]:::deepLinking + F022["F-022
Push Deep-Link Path Config"]:::deepLinking + F031["F-031
Push Notification Data Handling"]:::deepLinking + F035["F-035
Conversion Data Callback"]:::deepLinking + F036["F-036
App-Open Attribution Callback"]:::deepLinking + F037["F-037
UDL Callback & Models"]:::deepLinking + F039["F-039
Native iOS Deep-Link Entry Points"]:::deepLinking + F040["F-040
Android New-Intent Forwarding"]:::deepLinking + end + + subgraph oneLinkAndGrowth ["oneLinkAndGrowth"] + F027["F-027
Invite Link Generation"]:::oneLinkAndGrowth + F028["F-028
App Invite OneLink ID"]:::oneLinkAndGrowth + F029["F-029
Cross-Promotion Tracking"]:::oneLinkAndGrowth + F056["F-056
App Invite OneLink ID (init-time)"]:::oneLinkAndGrowth + end + + F002 --> F001 + F011 --> F001 + F011 --> F002 + F021 --> F015 + F035 --> F001 + F036 --> F001 + F037 --> F001 + F048 --> F001 + F057 --> F001 + F058 --> F001 + F059 --> F001 + + F023 --> F025 + F023 --> F038 + F024 --> F025 + F049 --> F051 + F049 --> F052 + F049 --> F054 + F050 --> F049 + F051 --> F049 + F052 --> F049 + F053 --> F049 + F053 --> F051 + F055 --> F049 + + F014 --> F037 + F022 --> F037 + F031 --> F022 + F037 --> F039 + F037 --> F040 + + F027 --> F028 + F027 --> F056 + F028 --> F056 + F029 --> F027 + F056 --> F028 + + classDef sdkCore fill:#4C6EF5,color:#fff + classDef purchaseValidation fill:#F59F00,color:#fff + classDef deepLinking fill:#E64980,color:#fff + classDef oneLinkAndGrowth fill:#7048E8,color:#fff +``` + +--- + +## Section 2 β€” Initialization Flow + +Features that configure, register, gate, or boot other features at startup time. `F-001` (SDK Initialization) is the sole boot entry point β€” every init-time option and startup-gated registration hangs off it directly. + +```mermaid +flowchart LR + F001["F-001 Β· SDK Initialization"]:::sdkCore + F002["F-002 Β· SDK Start"]:::sdkCore + F034["F-034 Β· Ad ID Collection Disable"]:::sdkCore + F037["F-037 Β· UDL Callback & Models"]:::deepLinking + F048["F-048 Β· Plugin Metadata Reporting"]:::sdkCore + F056["F-056 Β· App Invite OneLink ID (init-time)"]:::oneLinkAndGrowth + F057["F-057 Β· ASA Opt-out"]:::sdkCore + F058["F-058 Β· ATT Wait Timeout"]:::sdkCore + F059["F-059 Β· Debug Logging Toggle"]:::sdkCore + F011["F-011 Β· TCF/DMA Auto Consent"]:::sdkCore + + F001 -->|"gates start until manual-start configured"| F002 + F001 -->|"applies init-time disable flag"| F034 + F001 -->|"sets UDL registration flag"| F037 + F001 -->|"reports plugin type/version inline"| F048 + F001 -->|"applies init-time OneLink ID"| F056 + F001 -->|"applies init-time ASA opt-out"| F057 + F001 -->|"applies init-time ATT wait timeout"| F058 + F001 -->|"applies init-time debug flag"| F059 + F002 -->|"deferred start once CMP consent confirmed"| F011 + + classDef sdkCore fill:#4C6EF5,color:#fff + classDef deepLinking fill:#E64980,color:#fff + classDef oneLinkAndGrowth fill:#7048E8,color:#fff +``` + +--- + +## Section 3 β€” Dependency Table + +| Feature | Depends On | Note | +|---------|-----------|------| +| F-002 | F-001 | SDK session start only makes sense after init/options have been validated and passed to native | +| F-011 | F-001 | TCF auto-consent collection requires manual-start init configuration | +| F-011 | F-002 | TCF auto-consent defers the actual `startSDK()` call until CMP consent is confirmed | +| F-014 | F-037 | Manual deep-link re-trigger forces the native SDK to re-run the same UDL resolution path | +| F-021 | F-015 | iOS routes `setCustomerIdAndLogSession` to the identical native handler as plain `setCustomerUserId` | +| F-022 | F-037 | Push-notification deep-link path config only matters once a payload reaches UDL resolution | +| F-023 | F-025 | iOS validates against the sandbox/production endpoint set by the receipt-validation toggle | +| F-023 | F-038 | V1 validation delivers its async result through the legacy purchase-validation callback | +| F-024 | F-025 | iOS validates against the sandbox/production endpoint set by the receipt-validation toggle | +| F-027 | F-028 | Invite-link generation needs a base OneLink ID configured at runtime | +| F-027 | F-056 | Invite-link generation needs a base OneLink ID configured at init time (whichever wrote last wins) | +| F-028 | F-056 | Both setters write the same native OneLink-ID property β€” last write wins | +| F-029 | F-027 | iOS cross-promotion reuses the same invite-URL generator helper as invite-link generation | +| F-031 | F-022 | Push notification data handling resolves deep links using the registered JSON key-path | +| F-035 | F-001 | Conversion data delivery is gated by SDK init/start having registered the listener | +| F-036 | F-001 | App-open attribution delivery is gated by SDK init/start having registered the listener | +| F-037 | F-001 | UDL listener/delegate registration is gated by the UDL flag set during init | +| F-037 | F-039 | UDL resolution on iOS is fed by the native URL-scheme/Universal-Link/Scene entry points | +| F-037 | F-040 | UDL resolution on Android is fed by the new-intent forwarding entry point | +| F-048 | F-001 | Plugin metadata is reported inline as part of the native `initSdk` call | +| F-049 | F-051 | Android `configure()` requires the validation-result listener object as a constructor param | +| F-049 | F-052 | iOS `configure()` assigns the purchase-revenue delegate that the combined callback depends on | +| F-049 | F-054 | Purchase Connector only compiles/registers when the build-time opt-in is enabled | +| F-050 | F-049 | StoreKit version is packed into the shared `configure()` payload owned by F-049 | +| F-051 | F-049 | Android validation listeners only receive events once `configure()`/observation has started | +| F-052 | F-049 | iOS combined validation callback relies on the delegate wired during `configure()` | +| F-053 | F-049 | Data models are payload shapes exchanged only through the configured connector | +| F-053 | F-051 | Data models are referenced exclusively from the Android validation-result listener models | +| F-055 | F-049 | Guard exists specifically to catch use of Purchase Connector APIs before `configure()` runs | +| F-056 | F-028 | Both setters write the same native OneLink-ID property β€” last write wins | +| F-057 | F-001 | ASA opt-out is an init-time option validated/applied inside `initSdk` | +| F-058 | F-001 | ATT wait timeout is an init-time option validated/applied inside `initSdk` | +| F-059 | F-001 | Debug logging is an init-time option validated/applied inside `initSdk` | diff --git a/internal-docs/features/F-001-sdk-initialization.md b/internal-docs/features/F-001-sdk-initialization.md new file mode 100644 index 00000000..39a3a68a --- /dev/null +++ b/internal-docs/features/F-001-sdk-initialization.md @@ -0,0 +1,79 @@ +--- +id: F-001 +name: SDK Initialization & Options Validation +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +This is the entry point that wires the Flutter app's dev key, app ID and startup flags into the native AppsFlyer SDK. Without it, no other AppsFlyer API works: no attribution, no events, no deep linking. The Dart-side validation (`_validateAFOptions` / `_validateMapOptions`) catches misconfiguration early (missing dev key, malformed iOS numeric App Store ID) via `assert`s, and decides whether the SDK auto-starts or waits for an explicit `startSDK()` call (F-002). It also stamps the plugin's identity (`Plugin.FLUTTER` / `AFSDKPluginFlutter`) onto the native SDK so AppsFlyer's backend can attribute traffic to the Flutter wrapper. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called once by the host app immediately after constructing `AppsflyerSdk(options)`, typically in `main()` before `runApp()`. Runs whenever `initSdk()` is invoked, regardless of whether `AppsFlyerOptions` (typed) or a raw `Map` was passed to the factory constructor. + +--- + +## Call Chain +``` +AppsflyerSdk(options) factory [lib/src/appsflyer_sdk.dart] + β†’ AppsflyerSdk.private(...) [lib/src/appsflyer_sdk.dart] +AppsflyerSdk.initSdk({registerConversionDataCallback, registerOnAppOpenAttributionCallback, registerOnDeepLinkingCallback}) + β†’ _validateAFOptions(AppsFlyerOptions) | _validateMapOptions(Map) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("initSdk") β†’ initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().init(afDevKey, gcdListener, mContext) + β†’ instance.start(activity) [only if isManualStartMode == false] + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") β†’ initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerLib shared].appsFlyerDevKey / .appleAppID / .isDebug = ... + β†’ [[AppsFlyerLib shared] start] [only if manualStart == NO] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `initSdk`, `_validateAFOptions`, `_validateMapOptions` β€” validation + MethodChannel dispatch | +| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions` typed config model (devKey, appId, ATT wait time, manualStart, etc.) | +| `lib/src/appsflyer_constants.dart` | String keys shared across Dart/native (`AF_DEV_KEY`, `AF_APP_Id`, `AF_MANUAL_START`, `AF_GCD`, `AF_UDL`, `PLUGIN_VERSION`) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` β€” native Android init, conditional auto-start | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | Native Android mirror of the Dart string keys | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β€” native iOS init, conditional auto-start | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define` string keys (`afDevKey`, `afAppId`, `afManualStart`, …) and `kAppsFlyerPluginVersion` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `afDevKey` (String, required), `appId` (String, required on iOS β€” validated against `^\d{8,11}$`), `showDebug` (bool), `manualStart` (bool), `timeToWaitForATTUserAuthorization` (double, iOS only), `disableAdvertisingIdentifier` (bool), `disableCollectASA` (bool, iOS only), `appInviteOneLink` (String?), plus derived flags `GCD`/`UDL` computed from the `registerConversionDataCallback` / `registerOnAppOpenAttributionCallback` / `registerOnDeepLinkingCallback` parameters | +| **Output** | Native SDK instance initialized and, unless `manualStart: true`, started; Android returns `"success"` string to Dart, iOS returns `{"status": "OK"}`. Neither is currently exposed to the caller since `initSdk()`'s returned `Future` is rarely awaited for its value. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check initSdk call` (line 93) constructs `AppsflyerSdk.private(...)` with `mapOptions` and asserts the mocked channel receives `initSdk`. This exercises `_validateMapOptions` end-to-end but does not assert on the resulting validated map's contents, and does not cover `_validateAFOptions` (the typed `AppsFlyerOptions` path) or the iOS App ID regex / ATT-wait-time assertions at all β€” those run only under `Platform.isIOS`, which the Dart test environment does not satisfy. + +--- + +## Known Limitations +- Validation uses Dart `assert()`, which is stripped in release/profile builds β€” a missing `afDevKey` or malformed iOS `appId` will silently pass validation in release mode and only fail (or silently misbehave) once it reaches native code. +- The plugin version string is duplicated in three places and has drifted: Dart `AppsflyerConstants.PLUGIN_VERSION = "6.17.9"` (`lib/src/appsflyer_constants.dart`) vs. Android `AppsFlyerConstants.PLUGIN_VERSION = "6.18.0"` and iOS `kAppsFlyerPluginVersion = "6.18.0"` (matching `pubspec.yaml`'s `6.18.0`). The value reported to AppsFlyer's backend via `PluginInfo`/`setPluginInfoWith:` therefore differs from what `getVersionNumber()` (F-003) returns to the app. +- `disableCollectASA` and `timeToWaitForATTUserAuthorization` are only read/applied on iOS; on Android these options are silently ignored (no assertion or warning). +- Android's `initSdk` calls `result.success("success")` unconditionally at the end, even though `setDisableAdvertisingIdentifiers`, `subscribeForDeepLink`, etc. earlier in the method have no error handling β€” a native exception before that line surfaces to Flutter only as a generic platform exception, not one of the plugin's own error codes. + +--- + +## Dependencies +```mermaid +flowchart LR + F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-002-sdk-start.md b/internal-docs/features/F-002-sdk-start.md new file mode 100644 index 00000000..e4d4b554 --- /dev/null +++ b/internal-docs/features/F-002-sdk-start.md @@ -0,0 +1,79 @@ +--- +id: F-002 +name: SDK Start (auto/manual + result handler) +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-001"] +--- + +## Business Purpose +When `manualStart: true` is set on `AppsFlyerOptions` (F-001), the native SDK is initialized but deliberately does **not** begin sending sessions/attribution requests β€” this lets the host app gate the first network call behind consent collection (see F-011/F-012) or other startup preconditions. `startSDK()` is the trigger that actually opens the session. Without it, apps using manual-start mode would never attribute installs or sessions. The optional `onSuccess`/`onError` handler variant lets the app know definitively whether the first session request succeeded, which matters for CMP/consent flows that need to confirm the SDK is live before proceeding. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called explicitly by the host app after `initSdk()` when `manualStart: true` was configured. Also implicitly satisfied automatically inside `initSdk`/`initSdkWithCall:` on both platforms when `manualStart` is `false` (the default), meaning most apps never call `startSDK()` directly. + +--- + +## Call Chain +``` +AppsflyerSdk.startSDK({onSuccess, onError}) [lib/src/appsflyer_sdk.dart] + β†’ guards on _isSdkStarted (no-op if already started) + β†’ if onSuccess/onError provided: + _methodChannel.setMethodCallHandler(...) // listens for native "onSuccess"/"onError" + _methodChannel.invokeMethod('startSDKwithHandler') + β†’ Android: AppsflyerSdkPlugin.startSDKwithHandler(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().start(activity, null, AppsFlyerRequestListener) + β†’ onSuccess()/onError() β†’ mMethodChannel.invokeMethod("onSuccess"|"onError") + β†’ iOS: AppsflyerSdkPlugin.startSDKwithHandler:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] startWithCompletionHandler:^(...)] + β†’ [_methodChannel invokeMethod:@"onSuccess"|@"onError" ...] + β†’ else: + _methodChannel.invokeMethod('startSDK') + β†’ Android: AppsflyerSdkPlugin.startSDK(call, result) β†’ AppsFlyerLib.getInstance().start(activity) + β†’ iOS: AppsflyerSdkPlugin.startSDK:result: β†’ [[AppsFlyerLib shared] start] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `startSDK()` β€” guards double-start via `_isSdkStarted`, chooses handler vs. plain path | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `startSDK`, `startSDKwithHandler` β€” native start, posts `onSuccess`/`onError` back on the UI thread | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `startSDK:result:`, `startSDKwithHandler:result:` β€” native start, dispatches completion handler results on main queue | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | Optional `RequestSuccessListener onSuccess` and `RequestErrorListener onError` Dart callbacks; no other parameters | +| **Output** | No return value from `startSDK()` itself (`void`). If a handler was supplied, the native side invokes `onSuccess` with no arguments, or `onError(int errorCode, String errorMessage)` back through the Dart `MethodChannel.setMethodCallHandler`, after which the handler is torn down (`setMethodCallHandler(null)`) so it fires only once. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` covers `initSdk`, `setHost`, `logEvent`, etc., but has no test invoking `instance.startSDK(...)` in either its handler or plain form, and no test for the `_isSdkStarted` double-start guard. + +--- + +## Known Limitations +- `_isSdkStarted` is set to `true` as a side effect of `initSdk()` whenever `manualStart == false` (auto-start mode). If the host app then also calls `startSDK()` "just in case," the Dart guard silently no-ops it β€” this is correct behavior but is easy to misread as a bug when debugging why a manually-added `startSDK()` call appears to do nothing. +- If `startSDK()` is called with a handler and the native side never calls back (e.g. process death, or an unexpected exception path), the Dart method handler is never cleared and `_isSdkStarted` remains `true` forever, permanently blocking any future `startSDK()` call for that app session. +- On the `default` branch of the Android `setMethodCallHandler` switch (i.e. an unrecognized method name arrives), the Dart code resets `_isSdkStarted = false`, which would allow a subsequent `startSDK()` call to fire a second native `start()` β€” this branch is not currently exercised by any real native call and appears to be defensive/dead code. +- iOS's `startSDKwithHandler:` also registers a `UIApplicationDidBecomeActiveNotification` observer (`appDidBecomeActive`) as a side effect of the plain `startSDK:` path but not from within `startSDKwithHandler:` itself β€” foreground-resume auto-restart behavior differs subtly between the two start paths. + +--- + +## Dependencies +```mermaid +flowchart LR + F002["F-002 Β· SDK Start"]:::sdkCore -->|"only meaningful when manualStart is set during"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-003-sdk-plugin-version-retrieval.md b/internal-docs/features/F-003-sdk-plugin-version-retrieval.md new file mode 100644 index 00000000..5b031235 --- /dev/null +++ b/internal-docs/features/F-003-sdk-plugin-version-retrieval.md @@ -0,0 +1,72 @@ +--- +id: F-003 +name: SDK/Plugin Version Retrieval +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Support and QA need a reliable way to answer "which native AppsFlyer SDK build, and which Flutter plugin build, is actually running in this app?" `getSDKVersion()` surfaces the native SDK's own version string (useful for diagnosing SDK-side bugs against AppsFlyer's release notes), while `getVersionNumber()` surfaces the Flutter plugin wrapper's own version. Without these, bug reports and support tickets would rely on the app's `pubspec.yaml`/podspec pin, which does not confirm what was actually compiled into the running binary. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called on demand by host app code β€” typically diagnostic/support tooling, debug menus, or logging at startup. + +--- + +## Call Chain +``` +AppsflyerSdk.getSDKVersion() [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("getSDKVersion") + β†’ Android: AppsflyerSdkPlugin.onMethodCall("getSDKVersion") β†’ getSdkVersion(result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().getSdkVersion() + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("getSDKVersion") β†’ getSDKVersion:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] getSDKVersion] + +AppsflyerSdk.getVersionNumber() [lib/src/appsflyer_sdk.dart] + β†’ returns AppsflyerConstants.PLUGIN_VERSION (pure Dart constant, no channel call) [lib/src/appsflyer_constants.dart] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `getSDKVersion()` (async, native round-trip), `getVersionNumber()` (sync, local constant) | +| `lib/src/appsflyer_constants.dart` | `PLUGIN_VERSION` constant returned by `getVersionNumber()` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `getSdkVersion(result)` β€” proxies `AppsFlyerLib.getInstance().getSdkVersion()` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `getSDKVersion:result:` β€” proxies `[AppsFlyerLib shared] getSDKVersion]` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None | +| **Output** | `getSDKVersion()` β†’ `Future` β€” the native AppsFlyer SDK's own version string. `getVersionNumber()` β†’ `String` β€” the Flutter plugin's hardcoded version constant (synchronous, no native call). | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check getSDKVersion call` (line 192) asserts the mocked channel receives `getSDKVersion`. No test exists for `getVersionNumber()` (trivial, but untested). + +--- + +## Known Limitations +- `AppsflyerConstants.PLUGIN_VERSION` in Dart (`lib/src/appsflyer_constants.dart`) is hardcoded to `"6.17.9"`, while `pubspec.yaml`'s package version, Android's `AppsFlyerConstants.PLUGIN_VERSION`, and iOS's `kAppsFlyerPluginVersion` are all `"6.18.0"`. `getVersionNumber()` therefore returns a stale value one release behind the actual plugin version and the value the native layer reports upstream to AppsFlyer via `setPluginInfo`/`setPluginInfoWith:` (see F-001). This is a manual-bump constant with no single source of truth or CI check tying it to `pubspec.yaml`. +- `getVersionNumber()` reports the *plugin's* version, not the native SDK's version β€” the naming similarity to `getSDKVersion()` is a common source of confusion for integrators. + +--- + +## Dependencies +```mermaid +flowchart LR + F003["F-003 Β· SDK/Plugin Version Retrieval"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-004-in-app-event-logging.md b/internal-docs/features/F-004-in-app-event-logging.md new file mode 100644 index 00000000..8cf260b3 --- /dev/null +++ b/internal-docs/features/F-004-in-app-event-logging.md @@ -0,0 +1,73 @@ +--- +id: F-004 +name: In-App Event Logging +type: eventsAndRevenue +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +AppsFlyer's attribution model can only compute ROI (Return on Investment) and LTV (Lifetime Value) for media sources if the app reports what users actually *do* after install β€” purchases, tutorial completions, level-ups, subscriptions, etc. `logEvent` is the single funnel through which every custom in-app event (a name plus an arbitrary value map) reaches AppsFlyer's backend and is joined to the installing campaign/media-source. Without it, install attribution would exist in isolation with no downstream engagement or monetization signal, making campaign performance comparison and LTV/ROI reporting impossible. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app at any point after the SDK is initialized, whenever a business-significant in-app action occurs (e.g. purchase, level completion, tutorial finish, subscription). + +--- + +## Call Chain +``` +AppsflyerSdk.logEvent(eventName, eventValues) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("logEvent", {'eventName': ..., 'eventValues': ...}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("logEvent") β†’ logEvent(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().logEvent(mContext, eventName, eventValues) + β†’ result.success(true) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("logEvent") β†’ logEventWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] logEvent:eventName withValues:eventValues] + β†’ result(@YES) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `logEvent(String eventName, Map? eventValues)` β€” Dart public API, returns `Future` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logEvent(MethodCall, Result)` β€” reads `AF_EVENT_NAME`/`AF_EVENT_VALUES` args, forwards to `AppsFlyerLib.getInstance().logEvent(mContext, eventName, eventValues)`, always returns `result.success(true)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logEventWithCall:result:` β€” reads `eventName`/`eventValues` (normalizes `NSNull` to `nil`), forwards to `[[AppsFlyerLib shared] logEvent:withValues:]`, always returns `result(@YES)`; comment `//TODO: Add callback handler` marks that no completion callback is wired | +| `doc/InAppEvents.md` | Public integration guide with usage example | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `eventName` (String, required β€” AppsFlyer docs recommend ≀45 chars or the event is dropped from the dashboard but still visible in raw data); `eventValues` (Map, nullable β€” arbitrary event parameters, e.g. `af_revenue`, `af_content_id`) | +| **Output** | `Future` β€” on both platforms this resolves to `true` unconditionally once the native SDK call is *dispatched*; it does not reflect whether the event was actually delivered to/accepted by AppsFlyer's backend (no listener/callback is wired on either platform) | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check logEvent call` (line 115) awaits `logEvent("eventName", {"key": "val"})` against a mocked channel and asserts the channel receives the `logEvent` invocation; it only exercises the Dart-to-channel dispatch, not native behavior or the actual return value semantics. + +--- + +## Known Limitations +- **No delivery confirmation on either platform**: both native handlers call the fire-and-forget overload of the AppsFlyer SDK's `logEvent` (no `AppsFlyerRequestListener`/completion block) and immediately return `true`/`@YES`. A caller awaiting `logEvent()` gets no signal about whether the event actually reached AppsFlyer β€” the returned boolean only reflects "the method call was processed," not "the event was sent successfully." +- iOS explicitly documents this gap in-code: `//TODO: Add callback handler` in `logEventWithCall:result:`. +- No client-side validation of the 45-character event-name limit; events with longer names still get accepted by the plugin and are silently excluded from the AppsFlyer dashboard (only visible via raw data/Pull/Push APIs), per `doc/InAppEvents.md`. +- `eventValues` accepts an untyped `Map`, so type mismatches (e.g. non-JSON-serializable values) are only caught when the native SDK attempts to serialize the payload, not at the Dart call site. + +--- + +## Dependencies +```mermaid +flowchart LR + F004["F-004 Β· In-App Event Logging"]:::eventsAndRevenue + classDef eventsAndRevenue fill:#12B886,color:#fff +``` diff --git a/internal-docs/features/F-005-ad-revenue-logging.md b/internal-docs/features/F-005-ad-revenue-logging.md new file mode 100644 index 00000000..d3df7818 --- /dev/null +++ b/internal-docs/features/F-005-ad-revenue-logging.md @@ -0,0 +1,79 @@ +--- +id: F-005 +name: Ad Revenue Logging +type: eventsAndRevenue +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Apps that monetize through in-app advertising (rather than, or in addition to, direct purchases) need their ad-impression revenue attributed back to the campaigns/media sources that drove the installs β€” otherwise ROI/LTV reporting only sees purchase revenue and dramatically understates (or misses entirely) the true value of ad-monetized user cohorts. `logAdRevenue` reports a single ad-revenue event (network, mediation platform, currency, amount, optional extra params) to AppsFlyer so that ad monetization can be joined to install attribution the same way in-app purchase events are (see F-004). Removing it would blind AppsFlyer's dashboards to any revenue generated purely through ad impressions/clicks. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever a mediation SDK (AdMob, AppLovin MAX, ironSource, Unity, etc.) reports a paid ad impression/click, typically from within that mediation SDK's own revenue-paid callback. + +--- + +## Call Chain +``` +AppsflyerSdk.logAdRevenue(AdRevenueData) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("logAdRevenue", adRevenueData.toMap()) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("logAdRevenue") β†’ logAdRevenue(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ MediationNetwork.valueOf(mediationNetworkString.toUpperCase(Locale.ENGLISH)) + β†’ new AFAdRevenueData(monetizationNetwork, mediationNetwork, currencyIso4217Code, revenue) + β†’ AppsFlyerLib.getInstance().logAdRevenue(adRevenueData, additionalParameters) + β†’ result.success(true) | result.error(...) on invalid/unexpected input + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("logAdRevenue") β†’ logAdRevenue:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ getEnumValueFromString: maps the Dart enum's string value to AppsFlyerAdRevenueMediationNetworkType + β†’ [[AFAdRevenueData alloc] initWithMonetizationNetwork:mediationNetwork:currencyIso4217Code:eventRevenue:] + β†’ [[AppsFlyerLib shared] logAdRevenue:additionalParameters:] + β†’ (no result(...) call on the success path; result(...) is only invoked on error) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `logAdRevenue(AdRevenueData)` β€” Dart public API, `void`, serializes via `adRevenueData.toMap()` | +| `lib/src/appsflyer_ad_revenue_data.dart` | `AdRevenueData` model: `monetizationNetwork`, `mediationNetwork` (String), `currencyIso4217Code`, `revenue` (double), optional `additionalParameters` | +| `lib/src/appsflyer_constants.dart` | `AFMediationNetwork` enum with a `.value` getter mapping each case (e.g. `applovinMax`) to the exact lowercase/snake_case string (`"applovin_max"`) both native sides expect | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logAdRevenue(MethodCall, Result)` β€” validates required args via `requireNonNullArgument`, converts the mediation-network string to the native `MediationNetwork` enum via `.valueOf(...toUpperCase())`, builds `AFAdRevenueData`, calls `AppsFlyerLib.getInstance().logAdRevenue(...)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logAdRevenue:result:` and `getEnumValueFromString:` β€” validates required args, maps the mediation-network string to `AppsFlyerAdRevenueMediationNetworkType` via an explicit `NSDictionary` lookup table, builds `AFAdRevenueData`, calls `[[AppsFlyerLib shared] logAdRevenue:additionalParameters:]` | +| `doc/API.md` | `logAdRevenue` / `AdRevenueData` / `AFMediationNetwork` public documentation and usage example | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `monetizationNetwork` (String, required β€” the ad network the impression came from, e.g. "GoogleAdMob"); `mediationNetwork` (String, required β€” must equal one of `AFMediationNetwork.value`'s outputs, e.g. `"applovin_max"`); `currencyIso4217Code` (String, required); `revenue` (double, required); `additionalParameters` (Map, optional) | +| **Output** | Android: `Future` (unused by the `void` Dart method) resolving `result.success(true)` on success, or `result.error("INVALID_ARGUMENT_PROVIDED", ...)` for a missing/unrecognized field, or `result.error("UNEXPECTED_ERROR", ...)` for any other throwable. iOS: `result(...)` is only ever invoked on the error paths (`FlutterError` with codes such as `NULL_MONETIZATION_NETWORK`, `INVALID_MEDIATION_NETWORK`, `UNEXPECTED_ERROR`); on success the method returns without calling `result` at all. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check logAdRevenue call` (line 296) constructs an `AdRevenueData` with `AFMediationNetwork.applovinMax.value`, calls `logAdRevenue`, and asserts the mocked channel receives `logAdRevenue` with `mediationNetwork == 'applovin_max'`. This exercises only the Dart-to-channel dispatch and the enum-to-string mapping; it does not exercise either native handler's mediation-network parsing/validation logic. + +--- + +## Known Limitations +- **String-based mediation network mapping is duplicated three times** (Dart `AFMediationNetwork.value`, Android's `MediationNetwork.valueOf(...toUpperCase())`, iOS's hand-written `NSDictionary` in `getEnumValueFromString:`) with no shared source of truth β€” adding a new mediation network requires updating all three in lockstep, and a mismatch (e.g. a typo in one map) fails silently as an "unsupported network" error at runtime rather than a compile-time error. +- **iOS never resolves the Flutter result on success**: in `logAdRevenue:result:`, the success path calls `[[AppsFlyerLib shared] logAdRevenue:additionalParameters:]` and returns without ever calling `result(...)`. Since the Dart-side `logAdRevenue` is `void` and does not await a result, this is silent to callers today, but it means the platform channel's pending reply is simply never resolved on the happy path β€” asymmetric with Android, which always calls `result.success(true)`. +- Android's mediation-network parsing uses `.toUpperCase(Locale.ENGLISH)` then `MediationNetwork.valueOf(...)`; any string that doesn't exactly match a native enum constant after upper-casing (e.g. an unexpected value from a future `AFMediationNetwork` addition) throws `IllegalArgumentException`, caught and surfaced as `INVALID_ARGUMENT_PROVIDED` β€” but only after the Dart caller has already committed to that string via the shared enum, so failures depend on the plugin's native SDK dependency version staying in sync with `AFMediationNetwork`. +- No compile-time guarantee that `AdRevenueData.mediationNetwork` (a plain `String`) was actually built from `AFMediationNetwork.value` β€” passing an arbitrary string compiles fine and only fails at the native layer. + +--- + +## Dependencies +```mermaid +flowchart LR + F005["F-005 Β· Ad Revenue Logging"]:::eventsAndRevenue + classDef eventsAndRevenue fill:#12B886,color:#fff +``` diff --git a/internal-docs/features/F-006-custom-host-configuration.md b/internal-docs/features/F-006-custom-host-configuration.md new file mode 100644 index 00000000..80a1b86f --- /dev/null +++ b/internal-docs/features/F-006-custom-host-configuration.md @@ -0,0 +1,75 @@ +--- +id: F-006 +name: Custom Host Configuration +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Enterprises operating in regulated markets (e.g. China) or behind private network/CDN setups need the AppsFlyer SDK to send its HTTPS traffic to a non-default host. `setHost` lets the integrator redirect the SDK's network calls to a custom domain/prefix; `getHostName`/`getHostPrefix` let the app (or diagnostics tooling) confirm what is currently configured. Without this, apps requiring a custom collection endpoint could not integrate AppsFlyer at all in those environments. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app before/around SDK start, whenever the default AppsFlyer collection host must be overridden. `getHostName`/`getHostPrefix` are called on demand (e.g. debug screens) to read back the current configuration. + +--- + +## Call Chain +``` +AppsflyerSdk.setHost(hostPrefix, hostName) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setHost", {hostPrefix, hostName}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setHost") β†’ setHost(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setHost(hostPrefix, hostName) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setHost") β†’ setHost:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] setHost:hostName withHostPrefix:hostPrefix] + +AppsflyerSdk.getHostName() / getHostPrefix() + β†’ _methodChannel.invokeMethod("getHostName" | "getHostPrefix") + β†’ Android: getHostName(result) / getHostPrefix(result) β†’ AppsFlyerLib.getInstance().getHostName()/getHostPrefix() + β†’ iOS: getHostName:result: / getHostPrefix:result: β†’ [[AppsFlyerLib shared] host] / [[AppsFlyerLib shared] hostPrefix] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setHost`, `getHostName`, `getHostPrefix` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setHost`, `getHostName`, `getHostPrefix` native handlers | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `AF_HOST_PREFIX`, `AF_HOST_NAME` argument key constants | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setHost:result:`, `getHostName:result:`, `getHostPrefix:result:` native handlers | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `setHost`: `hostPrefix` (String), `hostName` (String). `getHostName`/`getHostPrefix`: none. | +| **Output** | `setHost` β†’ `void` (fire-and-forget). `getHostName()`/`getHostPrefix()` β†’ `Future` reflecting the currently configured values. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setHost call` (line 121) asserts the channel receives `setHost` with `hostPrefix`/`hostName` arguments; `check getHostPrefix call` (line 220) and `check getHostName call` (line 226) assert the corresponding method names are invoked. No test asserts the actual return value flowing back from a (mocked) native host name/prefix. + +--- + +## Known Limitations +- **Android bug**: `AppsflyerSdkPlugin.setHost(call, result)` never calls `result.success(...)` or `result.error(...)` β€” every other handler in the file does. Because Dart's `setHost()` is `void` and does not await the returned `Future`, this is currently harmless to callers, but it means the platform channel's pending reply for that invocation is left unresolved, unlike all other methods in this plugin, and would surface as a bug if a future refactor made `setHost` return/await a value. +- No input validation on `hostPrefix`/`hostName` on either platform β€” an empty string or malformed host is passed straight to the native SDK, which may fail silently or send traffic nowhere. +- Must be called before the SDK actually establishes its first network connection to take effect; calling it after `startSDK()`/auto-start has already fired a request may be too late β€” this ordering constraint is not enforced by the Dart or native code. + +--- + +## Dependencies +```mermaid +flowchart LR + F006["F-006 Β· Custom Host Configuration"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-007-device-id-collection-optout.md b/internal-docs/features/F-007-device-id-collection-optout.md new file mode 100644 index 00000000..c09617c3 --- /dev/null +++ b/internal-docs/features/F-007-device-id-collection-optout.md @@ -0,0 +1,71 @@ +--- +id: F-007 +name: Device ID Collection Opt-out (IMEI/Android ID) +type: sdkCore +platform: android +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Google Play policy prohibits apps that bundle Google Play Services from collecting IMEI or Android ID for advertising/attribution purposes; only apps without Play Services are allowed to rely on these identifiers as a fallback. `setCollectIMEI`/`setCollectAndroidId` let a Play-Services-enabled app explicitly opt out of this collection so it stays compliant, while apps without Play Services can leave it enabled as their only device-level identifier fallback. Getting this wrong risks Play Store policy violations and app rejection/removal. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during startup configuration, before or around SDK init, whenever the app needs to explicitly declare its IMEI/Android ID collection posture (typically apps that ship with Google Play Services present). + +--- + +## Call Chain +``` +AppsflyerSdk.setCollectIMEI(isCollect) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setCollectIMEI", {'isCollect': isCollect}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setCollectIMEI") β†’ setCollectIMEI(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setCollectIMEI(isCollect) + +AppsflyerSdk.setCollectAndroidId(isCollect) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setCollectAndroidId", {'isCollect': isCollect}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setCollectAndroidId") β†’ setCollectAndroidId(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setCollectAndroidID(isCollect) +``` +No iOS branch exists for either method name in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` β€” on iOS these calls fall through to `result(FlutterMethodNotImplemented)`. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setCollectIMEI(bool)`, `setCollectAndroidId(bool)` β€” platform-agnostic Dart API surface (no `Platform.isAndroid` guard) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCollectIMEI`, `setCollectAndroidId` native handlers | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `isCollect` (bool) β€” `true` keeps collection enabled (default SDK behavior), `false` opts out. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setCollectIMEI call` (line 232) and `check setCollectAndroidId call` (line 238) assert the mocked channel receives the respective method names. Tests run in the Dart test harness only, so they cannot and do not distinguish Android vs. iOS native behavior. + +--- + +## Known Limitations +- **Android-only**: there is no corresponding native implementation on iOS (concept doesn't apply β€” IMEI/Android ID are Android-specific identifiers). Calling these methods from a Flutter app running on iOS results in a `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer, since the Dart API has no platform guard and will happily invoke the channel method regardless of `Platform.isIOS`. +- No compile-time or runtime warning in the Dart layer indicates these are Android-only; integrators must consult documentation (or this catalog) to learn that. + +--- + +## Dependencies +```mermaid +flowchart LR + F007["F-007 Β· Device ID Collection Opt-out"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-008-manual-imei-android-id-override.md b/internal-docs/features/F-008-manual-imei-android-id-override.md new file mode 100644 index 00000000..35869e17 --- /dev/null +++ b/internal-docs/features/F-008-manual-imei-android-id-override.md @@ -0,0 +1,71 @@ +--- +id: F-008 +name: Manual IMEI/Android ID Override +type: sdkCore +platform: android +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Some apps already collect IMEI/Android ID themselves (e.g. via a legacy device-management SDK) and want AppsFlyer to reuse those values rather than re-reading them independently, or need to supply a value in contexts where the SDK's own read would fail (e.g. restricted permission states). `setImeiData`/`setAndroidIdData` let the host app hand these identifiers to the SDK directly instead of relying on its automatic collection (F-007 governs whether that automatic collection happens at all). + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during startup configuration when it already holds IMEI/Android ID values it wants to feed to AppsFlyer, in place of the SDK's own device-level collection. + +--- + +## Call Chain +``` +AppsflyerSdk.setImeiData(imei) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setImeiData", {'imei': imei}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setImeiData") β†’ setImeiData(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setImeiData(imei) + +AppsflyerSdk.setAndroidIdData(androidId) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setAndroidIdData", {'androidId': androidId}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setAndroidIdData") β†’ setAndroidIdData(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setAndroidIdData(androidId) +``` +No iOS branch exists for either method name in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:`. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setImeiData(String)`, `setAndroidIdData(String)` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setImeiData`, `setAndroidIdData` native handlers | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `setImeiData`: `imei` (String). `setAndroidIdData`: `androidId` (String). | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setImeiData call` (line 272) and `check setAndroidIdData call` (line 278) assert the mocked channel receives the respective method names. No assertion on the argument values actually reaching native code (only channel dispatch is exercised, per the test's mock architecture). + +--- + +## Known Limitations +- **Android-only**: no iOS implementation (IMEI/Android ID are not applicable identifiers on iOS). Same `MissingPluginException`/`FlutterMethodNotImplemented` risk as F-007 if called on iOS, since the Dart API is not platform-guarded. +- No format/length validation of the `imei`/`androidId` strings before they are handed to the native SDK β€” a malformed value would only surface as a data-quality problem downstream in AppsFlyer's reporting, not as a client-side error. + +--- + +## Dependencies +```mermaid +flowchart LR + F008["F-008 Β· Manual IMEI/Android ID Override"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-009-min-time-between-sessions.md b/internal-docs/features/F-009-min-time-between-sessions.md new file mode 100644 index 00000000..3e3eb6b0 --- /dev/null +++ b/internal-docs/features/F-009-min-time-between-sessions.md @@ -0,0 +1,69 @@ +--- +id: F-009 +name: Minimum Time Between Sessions +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +By default AppsFlyer starts a new session whenever the app returns to the foreground after being backgrounded, using the SDK's built-in threshold. Apps with unusual foreground/background patterns (e.g. quick task-switching flows, widget-driven relaunches) can get inflated session counts that distort engagement metrics. `setMinTimeBetweenSessions` lets the app widen (or narrow) that threshold so relaunches within the configured window are folded into the current session instead of counted as a new one, keeping session-based KPIs meaningful. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during startup configuration, before or shortly after SDK init, whenever the default session-splitting threshold needs to be overridden. + +--- + +## Call Chain +``` +AppsflyerSdk.setMinTimeBetweenSessions(seconds) [lib/src/appsflyer_sdk.dart] + β†’ assert(seconds >= 0) + β†’ _methodChannel.invokeMethod("setMinTimeBetweenSessions", {'seconds': seconds}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setMinTimeBetweenSessions") β†’ setMinTimeBetweenSessions(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setMinTimeBetweenSessions(seconds) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setMinTimeBetweenSessions") β†’ setMinTimeBetweenSessions:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerLib shared].minTimeBetweenSessions = seconds +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setMinTimeBetweenSessions(int)` β€” asserts non-negative seconds, dispatches to channel | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setMinTimeBetweenSessions` native handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setMinTimeBetweenSessions:result:` native handler (direct property assignment) | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `seconds` (int, must be `>= 0` per Dart `assert`) | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setMinTimeBetweenSessions call` (line 214) asserts the mocked channel receives `setMinTimeBetweenSessions`. The negative-seconds `assert` guard is not covered by any test. + +--- + +## Known Limitations +- The `seconds >= 0` guard is a Dart `assert()`, which is stripped in release/profile builds β€” a negative value passed in a release build reaches native code unchecked, where behavior is whatever the native SDK does with a negative threshold (undocumented in this repo). +- No upper-bound validation β€” an unreasonably large value (e.g. `Duration` misused as seconds) is not caught client-side. + +--- + +## Dependencies +```mermaid +flowchart LR + F009["F-009 Β· Minimum Time Between Sessions"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-010-currency-code-setting.md b/internal-docs/features/F-010-currency-code-setting.md new file mode 100644 index 00000000..4550b6d7 --- /dev/null +++ b/internal-docs/features/F-010-currency-code-setting.md @@ -0,0 +1,72 @@ +--- +id: F-010 +name: Currency Code Setting +type: eventsAndRevenue +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +AppsFlyer's revenue analytics (ROI/LTV dashboards) need a consistent currency to normalize monetary values reported through in-app purchase/revenue events. Apps that sell in a currency other than the SDK's USD default must declare that currency once via `setCurrencyCode`, so every subsequent in-app event's monetary value is interpreted (and converted for reporting) correctly. Without it, revenue figures for non-USD apps would be misreported or misinterpreted at AppsFlyer's default currency assumption, corrupting revenue-based attribution and LTV comparisons across campaigns. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app once, typically at startup (before or after logging revenue-bearing events), whenever the app's transactions are denominated in a non-default (non-USD) currency. + +--- + +## Call Chain +``` +AppsflyerSdk.setCurrencyCode(currencyCode) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setCurrencyCode", {'currencyCode': currencyCode}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setCurrencyCode") β†’ setCurrencyCode(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setCurrencyCode(currencyCode) + β†’ result.success(null) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setCurrencyCode") β†’ setCurrencyCode:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] setCurrencyCode:currencyCode] + β†’ result(nil) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setCurrencyCode(String currencyCode)` β€” platform-agnostic Dart API, `void` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCurrencyCode(MethodCall, Result)` β€” forwards to `AppsFlyerLib.getInstance().setCurrencyCode(currencyCode)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCurrencyCode:result:` β€” forwards to `[[AppsFlyerLib shared] setCurrencyCode:]` | +| `doc/API.md` | Public documentation for `setCurrencyCode` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `currencyCode` (String) β€” expected to be a 3-character ISO 4217 code (default is `"USD"` per the Dart doc comment) | +| **Output** | `void` on the Dart side; both native handlers call `result(nil)`/`result.success(null)` unconditionally β€” there is no validation or error signal if an invalid/malformed currency code is passed | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setCurrencyCode call` (line 129) calls `setCurrencyCode("USD")` and asserts the mocked channel receives `setCurrencyCode` with `currencyCode: 'USD'`; exercises only the Dart-to-channel dispatch, not native validation (since none exists) or actual downstream effect on event currency conversion. + +--- + +## Known Limitations +- **No format validation anywhere in the plugin**: neither the Dart API, nor the Android handler, nor the iOS handler check that `currencyCode` is a valid 3-letter ISO 4217 code. Any string (empty, too long, lowercase, non-existent code) is passed straight through to the native SDK; whether the native SDK itself validates or silently ignores an invalid code is outside this plugin's code and undocumented here. +- No API to read back the currently configured currency code β€” the plugin is write-only for this setting (unlike, e.g., `getHostName`/`getHostPrefix` for `setHost`). +- No enforced ordering relative to `initSdk()`/`startSDK()` or relative to `logEvent`/`logAdRevenue` calls; if called after revenue events have already been logged, prior events may retain the previous (default `"USD"`) currency depending on native SDK behavior, which is not something this plugin layer controls or documents. + +--- + +## Dependencies +```mermaid +flowchart LR + F010["F-010 Β· Currency Code Setting"]:::eventsAndRevenue + classDef eventsAndRevenue fill:#12B886,color:#fff +``` diff --git a/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md b/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md new file mode 100644 index 00000000..7bf6f923 --- /dev/null +++ b/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md @@ -0,0 +1,71 @@ +--- +id: F-011 +name: TCF/DMA Automatic Consent Collection +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-001", "F-002"] +--- + +## Business Purpose +The EU Digital Markets Act (DMA) requires gatekeepers like Google to obtain and forward user consent data before certain attribution/advertising interactions can occur. Rather than forcing every integrator to manually read Consent Management Platform (CMP) state and pass it to AppsFlyer via F-012's API, `enableTCFDataCollection` lets the SDK read TCF v2.2-formatted consent strings directly out of `SharedPreferences` (Android) / `NSUserDefaults` (iOS) β€” wherever a TCF-compliant CMP already stores them β€” and attach that consent data to every outgoing event automatically. Without this, apps using a CMP would need to duplicate the CMP's consent state into AppsFlyer's manual consent API themselves. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app once, typically at startup before SDK init. Per `doc/DMA.md`, the documented integration pattern is: (1) call `enableTCFDataCollection(true)`, (2) initialize the SDK with `manualStart: true` (F-001), (3) let the CMP present its consent dialog if needed, (4) once the CMP confirms consent data is stored, call `startSDK()` (F-002) so the first network request already carries the CMP-collected consent. + +--- + +## Call Chain +``` +AppsflyerSdk.enableTCFDataCollection(shouldCollect) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeListMethod("enableTCFDataCollection", {'shouldCollect': shouldCollect}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("enableTCFDataCollection") β†’ enableTCFDataCollection(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().enableTCFDataCollection(shouldCollect) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("enableTCFDataCollection") β†’ enableTCFDataCollection:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] enableTCFDataCollection:shouldCollect] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `enableTCFDataCollection(bool)` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `enableTCFDataCollection` native handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `enableTCFDataCollection:result:` native handler | +| `doc/DMA.md` | Integration guide documenting the required manual-start + CMP sequencing | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `shouldCollect` (bool) β€” `true` enables automatic TCF v2.2 string reads from platform storage. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. Downstream effect is that TCF consent strings are attached to subsequent SDK network requests. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check enableTCFDataCollection call` (line 308) asserts the mocked channel receives `enableTCFDataCollection`. No test verifies the documented manual-start/CMP sequencing, and no test exists for the interaction between this flag and `startSDK()`/`initSdk()` timing. + +--- + +## Known Limitations +- Dart's `enableTCFDataCollection` calls `_methodChannel.invokeListMethod(...)` (list-typed channel invocation) even though neither native handler returns a list β€” Android's handler returns `result.success(null)` and iOS's returns `result(nil)`. This mismatched invocation method works today only because the return value is discarded (`void` method, result not awaited); it is a latent inconsistency versus every other setter in this file, which use plain `invokeMethod`. +- The feature is purely a "read consent from storage" toggle β€” it does not validate that a TCF-compliant CMP is actually present or that the stored string is well-formed; if no CMP has written TCF data, the SDK simply finds nothing to read, with no error surfaced to the app. +- Correct behavior depends entirely on the app following the documented ordering (manual start β†’ CMP consent β†’ `startSDK()`); calling `enableTCFDataCollection` after the SDK has already auto-started (default `manualStart: false`) may mean the first session/event already went out without consent data attached. + +--- + +## Dependencies +```mermaid +flowchart LR + F011["F-011 Β· TCF/DMA Automatic Consent Collection"]:::sdkCore -->|"requires manualStart configured via"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + F011 -->|"deferred session start after CMP consent, via"| F002["F-002 Β· SDK Start"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md b/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md new file mode 100644 index 00000000..84c962bf --- /dev/null +++ b/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md @@ -0,0 +1,83 @@ +--- +id: F-012 +name: Manual GDPR/DMA Consent API (V1 + V2) +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Apps that don't rely on a TCF-compatible CMP (F-011) still need a way to legally record and forward the user's GDPR/DMA consent decisions before AppsFlyer collects or uses their data. `setConsentData`/`setConsentDataV2` are the manual counterpart: the app itself determines whether GDPR applies and what the user consented to, then hands that decision to the SDK explicitly. `setConsentDataV2` is the current, more granular API (adds `hasConsentForAdStorage`, supports nullable "not yet decided" states); `setConsentData` is the deprecated V1 shape kept for backward compatibility. Getting this right is a legal-compliance requirement, not just a UX nicety β€” incorrect or missing consent forwarding can put the integrating company in violation of GDPR/DMA. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app once consent has been captured from the user (via its own consent UI), and per `doc/DMA.md`, ideally called *before* `initSdk()` (or at least before `startSDK()` when using manual-start mode) so the very first SDK network request already carries the correct consent state. + +--- + +## Call Chain +``` +AppsflyerSdk.setConsentData(AppsFlyerConsent consentData) [DEPRECATED] [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod('setConsentData', {'consentData': consentData.toMap()}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setConsentData") β†’ setConsentData(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ new AppsFlyerConsent.forGDPRUser(...) | AppsFlyerConsent.forNonGDPRUser() + β†’ AppsFlyerLib.getInstance().setConsentData(consentData) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setConsentData") β†’ setConsentData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerConsent alloc] initForGDPRUserWith...] | initWithNonGDPRUser + β†’ [[AppsFlyerLib shared] setConsentData:consentData] + +AppsflyerSdk.setConsentDataV2({isUserSubjectToGDPR, consentForDataUsage, consentForAdsPersonalization, hasConsentForAdStorage}) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod('setConsentDataV2', {...}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setConsentDataV2") β†’ setConsentDataV2(call, result) β†’ getAppsFlyerConsentFromCall(call) [android/.../AppsflyerSdkPlugin.java] + β†’ new AppsFlyerConsent(isUserSubjectToGDPR, consentForDataUsage, consentForAdsPersonalization, hasConsentForAdStorage) + β†’ AppsFlyerLib.getInstance().setConsentData(consent) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setConsentDataV2") β†’ setConsentDataV2:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerConsent alloc] initWithIsUserSubjectToGDPR:...hasConsentForAdStorage:...] + β†’ [[AppsFlyerLib shared] setConsentData:consentData] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_consent.dart` | `AppsFlyerConsent` model β€” `forGDPRUser`/`nonGDPRUser` factories, `toMap()` (used by deprecated V1 API only) | +| `lib/src/appsflyer_sdk.dart` | `setConsentData` (`@Deprecated`), `setConsentDataV2` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setConsentData` (deprecated), `setConsentDataV2`, `getAppsFlyerConsentFromCall` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setConsentData:result:` (deprecated), `setConsentDataV2:result:` | +| `doc/DMA.md` | Full integration guide for both the CMP-automatic (F-011) and manual (this feature) consent paths | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | V1 (`setConsentData`): `AppsFlyerConsent` object β€” `isUserSubjectToGDPR` (bool), `hasConsentForDataUsage` (bool), `hasConsentForAdsPersonalization` (bool). V2 (`setConsentDataV2`): four independently-nullable named bools β€” `isUserSubjectToGDPR`, `consentForDataUsage`, `consentForAdsPersonalization`, `hasConsentForAdStorage` β€” `null` explicitly means "not yet decided," distinct from `false`. | +| **Output** | `void` for both β€” fire-and-forget on the Dart side. Android's `setConsentDataV2` wraps the native call in try/catch and returns a `CONSENT_ERROR` platform error to Dart on failure; iOS's V2 handler similarly catches `NSException` and returns a `CONSENT_ERROR` `FlutterError`. The deprecated V1 handlers on both platforms have no error handling. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` mocks `'setConsentData'` in its channel handler switch (line 56) but has **no test** that actually calls `instance.setConsentData(...)` or asserts on it β€” the mock case exists without a corresponding `test(...)` block. There is **no test at all**, mocked or otherwise, for `setConsentDataV2`, despite it being the currently recommended API per `doc/DMA.md`. + +--- + +## Known Limitations +- `setConsentData` (V1) is `@Deprecated('Use setConsentDataV2 instead')` in Dart, and `doc/DMA.md` explicitly flags it as deprecated, yet it remains fully wired end-to-end on both platforms with no runtime warning or removal timeline. +- V1's `AppsFlyerConsent` model (`lib/src/appsflyer_consent.dart`) forces `hasConsentForDataUsage`/`hasConsentForAdsPersonalization` to non-null booleans, which cannot represent an explicit "user has not yet decided" state β€” this is precisely the gap V2's nullable parameters were introduced to close. +- `setConsentDataV2` has zero test coverage despite being the actively recommended, DMA-critical API β€” a regression in its argument marshaling (e.g. a renamed key on one platform) would not be caught by the existing test suite. +- Both consent APIs are order-sensitive relative to `initSdk()`/`startSDK()` (must be called first to affect the initial request), but neither the Dart API nor either native handler enforces or warns about this ordering. + +--- + +## Dependencies +```mermaid +flowchart LR + F012["F-012 Β· Manual GDPR/DMA Consent API (V1 + V2)"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-013-user-anonymization.md b/internal-docs/features/F-013-user-anonymization.md new file mode 100644 index 00000000..725b3ec6 --- /dev/null +++ b/internal-docs/features/F-013-user-anonymization.md @@ -0,0 +1,69 @@ +--- +id: F-013 +name: User Anonymization (Opt-out logging) +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +When a specific user opts out of tracking (e.g. via an in-app privacy setting, or in response to a "do not track" regulatory requirement), the app needs a way to tell AppsFlyer to stop logging identifiable data for that user without tearing down the whole SDK. `anonymizeUser` flips this per-user opt-out flag on the native SDK. Without it, the only way to honor such a request would be the much blunter `stop()` API, which disables the SDK entirely rather than scoping the opt-out to one user. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever the current user's tracking-opt-out preference changes (e.g. a settings toggle, or an automated privacy-compliance check at login). + +--- + +## Call Chain +``` +AppsflyerSdk.anonymizeUser(shouldAnonymize) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("anonymizeUser", {'shouldAnonymize': shouldAnonymize}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("anonymizeUser") β†’ anonymizeUser(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().anonymizeUser(shouldAnonymize) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("anonymizeUser") β†’ anonymizeUser:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerLib shared].anonymizeUser = shouldAnonymize +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `anonymizeUser(bool)` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `anonymizeUser` native handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `anonymizeUser:result:` native handler (direct property assignment) | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `shouldAnonymize` (bool) β€” `true` enables anonymized logging for the current user, `false` restores normal logging. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not mock or call `anonymizeUser` anywhere, despite it being fully wired on both platforms. + +--- + +## Known Limitations +- No test coverage at all, unlike most other setters in this file β€” a regression in argument key naming (`shouldAnonymize`) on either platform would go undetected by CI. +- The flag is process/instance-scoped (it toggles a property on the shared `AppsFlyerLib`/native singleton), not tied to a specific customer user ID β€” if the app switches logged-in users without also resetting this flag, the anonymization state can leak across user sessions. +- No way to read back the current anonymization state from Dart (no `getAnonymizeUser()` counterpart) β€” the app must track the last value it set itself. + +--- + +## Dependencies +```mermaid +flowchart LR + F013["F-013 Β· User Anonymization (Opt-out logging)"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-014-manual-deep-link-retrigger.md b/internal-docs/features/F-014-manual-deep-link-retrigger.md new file mode 100644 index 00000000..7d57405b --- /dev/null +++ b/internal-docs/features/F-014-manual-deep-link-retrigger.md @@ -0,0 +1,71 @@ +--- +id: F-014 +name: Manual Deep-Link Re-trigger (performOnDeepLinking) +type: deepLinking +platform: android +status: active +last_verified: 2026-07-15 +depends_on: ["F-037"] +--- + +## Business Purpose +Apps that delay `startSDK()` (manual-start mode) can miss deep-link resolution for the launch intent, because AppsFlyer normally inspects the intent during its own lifecycle hooks around SDK start. `performOnDeepLinking()` lets the host app force the native SDK to re-process the activity's current intent on demand β€” typically right before a delayed `startSDK()` call β€” so a OneLink click that launched the app is still resolved even though initialization was deferred. Without this API, manual-start integrators on Android would silently lose deep-link data for the launch that started the app. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called explicitly by the host app, typically in a manual-start (`manualStart: true`) flow, immediately before invoking `startSDK()`/`startSDKwithHandler()` β€” e.g. after the app has finished its own startup gating (consent, config fetch, etc.) but still needs the original launch intent resolved for deep linking. + +--- + +## Call Chain +``` +AppsflyerSdk.performOnDeepLinking() [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("performOnDeepLinking") + β†’ Android: AppsflyerSdkPlugin.onMethodCall("performOnDeepLinking") β†’ performOnDeepLinking(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ intent = activity.getIntent() + β†’ AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication) + β†’ afDeepLinkListener.onDeepLinking(DeepLinkResult) [if UDL subscribed] + β†’ runOnUIThread(..., AF_UDL_CALLBACK, AF_SUCCESS) β†’ callbackChannel "callListener" β†’ Dart onDeepLinking callback (see F-037) + β†’ iOS: no "performOnDeepLinking" case in AppsflyerSdkPlugin.m's handleMethodCall: β†’ FlutterMethodNotImplemented +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `performOnDeepLinking()` β€” platform-agnostic Dart API, no `Platform.isAndroid` guard | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `performOnDeepLinking(call, result)` β€” reads `activity.getIntent()` and forwards it to `AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | No corresponding case in `handleMethodCall:` β€” the method name is entirely absent | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None (no arguments passed from Dart) | +| **Output** | Android: `void`; internally errors `"NO_INTENT"` if `activity.getIntent()` is null, or `"NO_ACTIVITY"` if the activity is null (Dart call is fire-and-forget and does not await/inspect these). No direct return value β€” the actual payload, if any, arrives asynchronously via the `onDeepLinking` callback (F-037). iOS: `MissingPluginException` / `FlutterMethodNotImplemented` since the method is unhandled. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `performOnDeepLinking`. + +--- + +## Known Limitations +- **iOS has no implementation at all** β€” unlike most other Dart APIs in this plugin, `performOnDeepLinking` is not merely a no-op stub on iOS (compare `setIsUpdate` in F-016); the method name doesn't appear in `AppsflyerSdkPlugin.m`'s `handleMethodCall:` chain, so the platform channel call falls through to `FlutterMethodNotImplemented`. Since the Dart method doesn't await or handle the channel result, this failure is silent to the caller. +- Documented as "Android Only!" in `doc/API.md`, confirming this is a deliberate platform restriction rather than an oversight β€” but the Dart API surface gives no compile-time signal of this, so cross-platform code calling it unconditionally will throw on iOS at the channel layer. +- Depends on `activity` and `activity.getIntent()` being non-null at call time; if the Flutter engine is detached from its activity (e.g. during a configuration change), the call errors out natively but this is invisible to the fire-and-forget Dart caller. + +--- + +## Dependencies +```mermaid +flowchart LR + F014["F-014 Β· Manual Deep-Link Re-trigger"]:::deepLinking -->|"result surfaces through"| F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking + classDef deepLinking fill:#E64980,color:#fff +``` diff --git a/internal-docs/features/F-015-customer-user-id.md b/internal-docs/features/F-015-customer-user-id.md new file mode 100644 index 00000000..83a1fe0e --- /dev/null +++ b/internal-docs/features/F-015-customer-user-id.md @@ -0,0 +1,69 @@ +--- +id: F-015 +name: Customer User ID (CUID) +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +AppsFlyer generates its own device-scoped unique ID (`getAppsFlyerUID`), but businesses need to join AppsFlyer's attribution/reporting data (CSV exports, Postback APIs) against their own internal user records (account ID, CRM ID, etc.). `setCustomerUserId` lets the app register its own developer-defined ID alongside AppsFlyer's, so every report and postback can be cross-referenced against the app's own user database β€” without it, correlating AppsFlyer attribution data with internal user analytics would require a fragile, manual matching process. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever it knows the user's internal identifier β€” typically right after login/signup, or as soon as the app's own user-identity system resolves an ID. + +--- + +## Call Chain +``` +AppsflyerSdk.setCustomerUserId(id) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setCustomerUserId", {'id': id}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setCustomerUserId") β†’ setCustomerUserId(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setCustomerUserId(userId) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setCustomerUserId") β†’ setCustomerUserId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] setCustomerUserID:userId] +``` +Note: the related (but distinct) Dart API `setCustomerIdAndLogSession(id)` invokes the channel method `"setCustomerIdAndLogSession"`, which Android handles with its own `setCustomerIdAndLogSession(call, result)` (calling `AppsFlyerLib.getInstance().setCustomerIdAndLogSession(userId, mContext)`), while iOS routes `"setCustomerIdAndLogSession"` to the *same* `setCustomerUserId:result:` handler as plain `setCustomerUserId` β€” iOS has no distinct "and log session" native behavior. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setCustomerUserId(String)` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCustomerUserId` native handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCustomerUserId:result:` native handler | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `id` (String) β€” the developer-defined customer user ID. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setCustomerUserId call` (line 266) asserts the mocked channel receives `setCustomerUserId`. No assertion on the argument value reaching native code beyond the channel dispatch mock. + +--- + +## Known Limitations +- No validation of the `id` string (empty string, whitespace, excessive length) before it is forwarded to native code β€” a blank ID is passed through unchanged on both platforms. +- iOS silently reuses the plain `setCustomerUserId:` implementation for the separate `setCustomerIdAndLogSession` Dart API, while Android gives it genuinely distinct native behavior (`setCustomerIdAndLogSession(userId, mContext)`, tied to `waitForCustomerUserId`'s delayed-session-log flow) β€” cross-platform behavior for that related API is not equivalent, which is easy to miss since both share the same underlying `setCustomerUserId` naming. + +--- + +## Dependencies +```mermaid +flowchart LR + F015["F-015 Β· Customer User ID (CUID)"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-016-update-vs-fresh-install-flag.md b/internal-docs/features/F-016-update-vs-fresh-install-flag.md new file mode 100644 index 00000000..f8bdd8fc --- /dev/null +++ b/internal-docs/features/F-016-update-vs-fresh-install-flag.md @@ -0,0 +1,68 @@ +--- +id: F-016 +name: Update vs. Fresh-Install Flag +type: sdkCore +platform: android +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Attribution logic needs to distinguish "this session came from a brand-new install" versus "this session came from an app that was just updated" β€” misclassifying updates as new installs would corrupt install-attribution counts and inflate campaign performance numbers. `setIsUpdate` lets the app tell the native SDK explicitly that the current launch follows an update (e.g. detected by comparing a stored app-version marker against the running version), which the SDK factors into its session/attribution logic on Android. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app at startup, after the app has itself determined (typically by comparing a persisted last-known app version against the current one) that this launch follows an update rather than a fresh install. + +--- + +## Call Chain +``` +AppsflyerSdk.setIsUpdate(isUpdate) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setIsUpdate", {'isUpdate': isUpdate}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setIsUpdate") β†’ setIsUpdate(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setIsUpdate(isUpdate) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setIsUpdate") β†’ (no-op) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setIsUpdate(bool)` β€” platform-agnostic Dart API (no `Platform.isAndroid` guard) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setIsUpdate` native handler β€” forwards to `AppsFlyerLib.getInstance().setIsUpdate(isUpdate)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `handleMethodCall:` contains an empty `else if([@"setIsUpdate" isEqualToString:call.method]){ }` branch β€” matched but intentionally does nothing | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `isUpdate` (bool) | +| **Output** | Android: `void`, fire-and-forget, and `result.success(null)` is called so the Dart-side `Future` (if awaited) would resolve normally. iOS: the method-call branch matches but never calls `result(...)` at all. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setIsUpdate call` (line 136) asserts the mocked channel receives `setIsUpdate` with `isUpdate: true`, exercising only the Dart-to-channel dispatch (the mock test harness cannot and does not distinguish Android's real handling from iOS's no-op). + +--- + +## Known Limitations +- **iOS is a documented no-op**: in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:`, the `"setIsUpdate"` branch is matched (`if([@"setIsUpdate" isEqualToString:call.method]){ }`) but its body is empty β€” no native AppsFlyer API is called, and critically, `result(...)` is never invoked either. Since this branch matches inside an `if/else if` chain, control does not fall through to the trailing `result(FlutterMethodNotImplemented)` β€” the platform channel's pending reply for `setIsUpdate` on iOS is simply never resolved. Dart's `setIsUpdate()` is `void` and does not await the result, so this is silent to the caller today, but the update-vs-install distinction this API is meant to convey has **no effect whatsoever on iOS** β€” only Android attribution logic actually receives it. +- The Dart API has no platform guard and gives no compile-time or runtime signal that calling `setIsUpdate` on iOS is a no-op; an integrator relying on it cross-platform would reasonably but incorrectly assume parity with Android. +- No enforced ordering relative to `initSdk()` β€” the native SDK's own documentation-level expectation (call before init so the flag is available for the very first session) is not validated by either native handler. + +--- + +## Dependencies +```mermaid +flowchart LR + F016["F-016 Β· Update vs. Fresh-Install Flag"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-017-sdk-kill-switch.md b/internal-docs/features/F-017-sdk-kill-switch.md new file mode 100644 index 00000000..064a0829 --- /dev/null +++ b/internal-docs/features/F-017-sdk-kill-switch.md @@ -0,0 +1,70 @@ +--- +id: F-017 +name: SDK Kill Switch (stop) +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Some legal, privacy, or contractual situations (e.g. a user invokes a "right to be forgotten," a regulator order, or a licensing dispute) require the app to fully halt all AppsFlyer network activity immediately, not just for one user but for the whole SDK instance. `stop(true)` is the bluntest tool in the plugin: it tells the native SDK to stop communicating with AppsFlyer's servers entirely. Without it, the only way to achieve the same effect would be to prevent the SDK from ever calling `initSdk()`/`startSDK()`, which is not possible once the app is already running with the SDK live. This is documented as an "extreme case" API for legal/privacy compliance, distinct from the narrower per-user `anonymizeUser` (F-013). + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app at any point during the app's lifetime β€” typically in response to a privacy/legal requirement (e.g. a "kill switch" remote config flag, a consent withdrawal flow, or during automated compliance testing) β€” to start or stop all SDK network communication. + +--- + +## Call Chain +``` +AppsflyerSdk.stop(isStopped) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("stop", {'isStopped': isStopped}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("stop") β†’ stop(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().stop(isStopped, mContext) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("stop") β†’ stop:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerLib shared].isStopped = stop +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `stop(bool)` β€” Dart API surface | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `stop(call, result)` native handler, line 1033 | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `stop:result:` native handler (direct property assignment), line 734 | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `isStopped` (bool) β€” `true` halts all SDK network communication/activity; `false` re-enables it. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check stop call` (line 143) asserts the mocked channel receives method `'stop'` with `capturedArguments['isStopped'] == true`. Native Android/iOS behavior is not exercised by any Dart test. + +--- + +## Known Limitations +- No way to read back the current stopped state from Dart β€” the host app must track the last value it set itself. +- Calling `stop(true)` does not clear or reset any previously buffered/queued native SDK state; resuming with `stop(false)` re-enables communication but the plugin doc explicitly frames this as an "extreme" API not meant for routine toggling. +- No test coverage of the native Android/iOS code paths, only the Dart-to-channel argument shape. +- Distinct from `anonymizeUser` (F-013): `stop` disables the entire SDK instance for all users/sessions, while `anonymizeUser` scopes an opt-out to the current user only. Using `stop` where `anonymizeUser` was intended would be a significant over-reach in production. + +--- + +## Dependencies +```mermaid +flowchart LR + F017["F-017 Β· SDK Kill Switch (stop)"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-018-uninstall-measurement.md b/internal-docs/features/F-018-uninstall-measurement.md new file mode 100644 index 00000000..f7d12595 --- /dev/null +++ b/internal-docs/features/F-018-uninstall-measurement.md @@ -0,0 +1,74 @@ +--- +id: F-018 +name: Uninstall Measurement +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Attribution isn't just about installs β€” media sources and marketers also need to measure uninstalls to calculate true retention/ROI. AppsFlyer measures uninstalls by receiving silent push notifications and needs the device's push token registered against the install. `updateServerUninstallToken` is how the host app hands that token (FCM token on Android, APNs device token on iOS) to the native SDK. Without it, uninstall events never reach AppsFlyer's backend and uninstall-based campaign reporting/ROI calculations would be silently incomplete. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever it obtains/refreshes its push token β€” typically inside a Firebase Messaging (`FirebaseMessaging.instance.getToken()` on Android / `getAPNSToken()` on iOS) callback, or from native `didRegisterForRemoteNotificationsWithDeviceToken:` on iOS. + +--- + +## Call Chain +``` +AppsflyerSdk.updateServerUninstallToken(token) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("updateServerUninstallToken", {'token': token}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("updateServerUninstallToken") β†’ updateServerUninstallToken(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().updateServerUninstallToken(mContext, token) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("updateServerUninstallToken") β†’ updateServerUninstallToken:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ hex-string token manually decoded into NSData + β†’ [AppsFlyerLib shared] registerUninstall:deviceTokenData] + +AppsflyerSdk.enableUninstallTracking(senderId) [DEPRECATED β€” no-op] [lib/src/appsflyer_sdk.dart] + β†’ prints a deprecation message only; does not invoke the method channel at all +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `updateServerUninstallToken(String)` (active), `enableUninstallTracking(String)` (`@Deprecated`, no-op) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `updateServerUninstallToken(call, result)`, line 1027 | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `updateServerUninstallToken:result:`, line 740 β€” converts hex-string token to `NSData` before calling `registerUninstall:` | +| `doc/AdvancedAPI.md` | "Measure App Uninstalls" section documents both the iOS-native (`registerUninstall:` in `AppDelegate.m`) and plugin-side paths, and the Firebase Messaging integration pattern | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `token` (String) β€” Android: FCM registration token, passed through as-is. iOS: APNs device token as a **hexadecimal string** (e.g. from `FirebaseMessaging.instance.getAPNSToken()`); the plugin strips spaces and manually converts each hex byte pair into raw `NSData` before calling `registerUninstall:`. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check updateServerUninstallToken call` (line 150) asserts the mocked channel receives `'updateServerUninstallToken'` with `capturedArguments['token'] == 'token123'`. No test exercises `enableUninstallTracking` (there is nothing to assert β€” it never touches the channel), and no test covers the iOS hex-to-`NSData` conversion logic. + +--- + +## Known Limitations +- `enableUninstallTracking(senderId)` is `@Deprecated` and, unlike most other deprecated methods in this file, has been fully gutted β€” it only prints a message and does nothing else, even though the `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` method-dispatch table still has a (no-op) `enableUninstallTracking` branch left over from the old implementation. +- On iOS, `updateServerUninstallToken`'s hex-string parsing has no length/format validation β€” a malformed or odd-length hex string will silently produce truncated/incorrect `NSData` rather than raising an error back to Dart. +- The app is responsible for obtaining and refreshing the push token itself (e.g. via `firebase_messaging`); this API only forwards whatever string it is given, so a stale or missing token upstream silently degrades uninstall measurement with no error surfaced to the caller. + +--- + +## Dependencies +```mermaid +flowchart LR + F018["F-018 Β· Uninstall Measurement"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-019-user-email-collection.md b/internal-docs/features/F-019-user-email-collection.md new file mode 100644 index 00000000..adb200f3 --- /dev/null +++ b/internal-docs/features/F-019-user-email-collection.md @@ -0,0 +1,73 @@ +--- +id: F-019 +name: User Email Collection (with encryption) +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Some attribution and cross-device matching scenarios benefit from AppsFlyer knowing the user's email address(es) (e.g. matching web and app sessions for the same customer). Sending raw emails over the network is a privacy concern, so `setUserEmails` supports an optional SHA-256 hash instead of plaintext. Without this API, integrators wanting to correlate identities by email would have no supported channel to hand that data to the native SDK at all. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app once the user's email(s) become known β€” typically right after login/signup, or whenever the app wants to (re)associate the current session with one or more email addresses. + +--- + +## Call Chain +``` +AppsflyerSdk.setUserEmails(emails, cryptType) [lib/src/appsflyer_sdk.dart] + β†’ cryptTypeInt = EmailCryptType.values.indexOf(cryptType) (defaults to 0 / EmailCryptTypeNone if omitted) [lib/src/appsflyer_constants.dart] + β†’ _methodChannel.invokeMethod("setUserEmails", {'emails': emails, 'cryptType': cryptTypeInt}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setUserEmails") β†’ setUserEmails(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ maps cryptTypeInt (0/1) to AppsFlyerProperties.EmailsCryptType.NONE / SHA256 (throws InvalidParameterException on any other value) + β†’ AppsFlyerLib.getInstance().setUserEmails(cryptType, emails.toArray(new String[0])) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setUserEmails") β†’ setUserEmails:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ maps cryptTypeInt to native EmailCryptType (EmailCryptTypeNone / EmailCryptTypeSHA256) + β†’ [AppsFlyerLib shared] setUserEmails:cryptType:] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setUserEmails(List, [EmailCryptType?])` β€” converts the enum to its integer index before sending | +| `lib/src/appsflyer_constants.dart` | `enum EmailCryptType { EmailCryptTypeNone, EmailCryptTypeSHA256 }` β€” index 0/1 is the wire format sent to native | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setUserEmails(call, result)`, line 983 β€” maps int to `AppsFlyerProperties.EmailsCryptType`, throws on unrecognized value | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setUserEmails:result:`, line 761 β€” maps int to native `EmailCryptType` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `emails` (`List`, required) β€” one or more user email addresses. `cryptType` (`EmailCryptType?`, optional) β€” `EmailCryptTypeNone` (default, index 0) sends plaintext; `EmailCryptTypeSHA256` (index 1) hashes before sending. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setUserEmails call` (line 244) calls `setUserEmails(["user@example.com"], EmailCryptType.EmailCryptTypeSHA256)` and asserts `capturedArguments['emails']` contains the email and `capturedArguments['cryptType']` equals the enum's index (1). The default (omitted `cryptType`, defaulting to index 0) path is not separately tested. + +--- + +## Known Limitations +- The enum-to-int mapping (`EmailCryptType.values.indexOf(cryptType)`) is a fragile contract: if the enum's declared order in `lib/src/appsflyer_constants.dart` is ever changed or a new value is inserted in the middle, the integer sent over the channel silently shifts meaning on both native platforms without any compile-time check tying the three enumerations together. +- Android throws a Java `InvalidParameterException` for any `cryptTypeInt` outside `{0, 1}` β€” since the only public Dart entry point is the typed enum, this should be unreachable in practice, but a raw/dynamic method channel call bypassing the Dart API could trigger it. +- No corresponding getter exists to read back which emails/crypt type were last set. + +--- + +## Dependencies +```mermaid +flowchart LR + F019["F-019 Β· User Email Collection (with encryption)"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-020-appsflyer-uid-retrieval.md b/internal-docs/features/F-020-appsflyer-uid-retrieval.md new file mode 100644 index 00000000..2fec51c9 --- /dev/null +++ b/internal-docs/features/F-020-appsflyer-uid-retrieval.md @@ -0,0 +1,68 @@ +--- +id: F-020 +name: AppsFlyer UID Retrieval +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Every install gets a unique AppsFlyer-generated device/install ID, which is the primary key AppsFlyer uses internally to tie together attribution, in-app events, and reporting for that install. Host apps often need this same ID for their own backend correlation (e.g. sending it alongside server-side purchase records, or cross-referencing support tickets with AppsFlyer's dashboard/raw-data reports). `getAppsFlyerUID()` is the only supported way to read that ID from Dart. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called on demand by the host app β€” typically after SDK init, to attach the AppsFlyer ID to internal analytics, support diagnostics, or server-side event payloads. + +--- + +## Call Chain +``` +AppsflyerSdk.getAppsFlyerUID() [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("getAppsFlyerUID") + β†’ Android: AppsflyerSdkPlugin.onMethodCall("getAppsFlyerUID") β†’ getAppsFlyerUID(result) [android/.../AppsflyerSdkPlugin.java] + β†’ result.success(AppsFlyerLib.getInstance().getAppsFlyerUID(mContext)) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("getAppsFlyerUID") β†’ getAppsFlyerUID:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ result([[AppsFlyerLib shared] getAppsFlyerUID]) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `getAppsFlyerUID()` β€” `Future` async round-trip | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `getAppsFlyerUID(result)`, line 797 | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `getAppsFlyerUID:result:`, line 602 | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None | +| **Output** | `Future` β€” the AppsFlyer-generated unique ID for this install; may resolve to `null`/empty if the SDK has not finished initializing/generating the ID yet. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check getAppsFlyerUID call` (line 198) asserts the mocked channel receives `'getAppsFlyerUID'`. The test does not stub a return value, so the resolved-ID contract (nullable String) is not exercised. + +--- + +## Known Limitations +- No documented guarantee of the ID's availability timing relative to `initSdk()`/`startSDK()` β€” calling it too early (before the native SDK has generated/persisted the ID) can return an empty string or `null` depending on platform/SDK version, and the Dart API gives no way to await "ID ready." +- No test coverage of the actual resolved value or of null/empty-string edge cases on either platform. + +--- + +## Dependencies +```mermaid +flowchart LR + F020["F-020 Β· AppsFlyer UID Retrieval"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-021-delayed-session-start-pending-cuid.md b/internal-docs/features/F-021-delayed-session-start-pending-cuid.md new file mode 100644 index 00000000..44f71c32 --- /dev/null +++ b/internal-docs/features/F-021-delayed-session-start-pending-cuid.md @@ -0,0 +1,77 @@ +--- +id: F-021 +name: Delayed Session Start Pending CUID +type: sdkCore +platform: android +status: active +last_verified: 2026-07-15 +depends_on: ["F-015"] +--- + +## Business Purpose +Some apps only know the user's own customer ID (CUID) after login, but want every session β€” including the very first one β€” attributed with that ID rather than logging an "anonymous" session first. `waitForCustomerUserId(true)` tells the SDK to hold off logging the launch/session event until `setCustomerIdAndLogSession()` explicitly supplies the CUID and unblocks it. Without this pair of APIs, an app that authenticates after launch would either lose the CUID association on the first session or have to accept an anonymous first session in its AppsFlyer reporting. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +`waitForCustomerUserId(true)` is called during startup configuration (typically before or instead of relying on auto-start) to arm the delay. `setCustomerIdAndLogSession(id)` is called later, once the app has resolved the user's customer ID (e.g. after login), to supply the ID and release the held session. + +--- + +## Call Chain +``` +AppsflyerSdk.waitForCustomerUserId(wait) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("waitForCustomerUserId", {'wait': wait}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("waitForCustomerUserId") β†’ waitForCustomerUserId(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().waitForCustomerUserId(wait) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("waitForCustomerUserId") β†’ waitForCustomerId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ NO-OP β€” the method body only calls result(nil); no native AppsFlyerLib API is invoked + +AppsflyerSdk.setCustomerIdAndLogSession(id) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setCustomerIdAndLogSession", {'id': id}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setCustomerIdAndLogSession") β†’ setCustomerIdAndLogSession(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setCustomerIdAndLogSession(id, mContext) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setCustomerIdAndLogSession") β†’ setCustomerUserId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ routed to the same handler as plain setCustomerUserId β€” [AppsFlyerLib shared] setCustomerUserID:id]; no "log session" semantics +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `waitForCustomerIdAndLogSession` split into `waitForCustomerUserId(bool)` and `setCustomerIdAndLogSession(String)` β€” no `Platform.isAndroid` guard on either | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `waitForCustomerUserId(call, result)` (line 971), `setCustomerIdAndLogSession(call, result)` (line 1009) β€” both proxy real native APIs | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `waitForCustomerId:result:` (line 757, no-op stub), `setCustomerIdAndLogSession` dispatch aliased to `setCustomerUserId:result:` (line 107/722) | +| `doc/API.md` | Explicitly documents both APIs as **"Android only!"** (lines 440, 449) | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `waitForCustomerUserId`: `wait` (bool) β€” `true` delays session logging until a CUID is set. `setCustomerIdAndLogSession`: `id` (String) β€” the customer user ID to attach and the trigger to release the held session. | +| **Output** | `void` for both β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check waitForCustomerUserId call` (line 260) asserts the mocked channel receives `'waitForCustomerUserId'`. No test exists for `setCustomerIdAndLogSession` β€” it is absent from the mock handler's recognized-method switch (line 24-66) entirely, so calling it in a test would not even register as a captured method. + +--- + +## Known Limitations +- **Effectively Android-only, despite no platform guard in Dart.** On iOS, `waitForCustomerId:` is a hollow stub (`result(nil)` only) β€” calling `waitForCustomerUserId(true)` on iOS has zero effect on session logging. `setCustomerIdAndLogSession` on iOS is silently routed to the same code as plain `setCustomerUserId` (just sets the customer ID property) with no "wait/release" behavior at all. This means an app that relies on this feature to guarantee CUID-attributed first sessions gets that guarantee only on Android; on iOS the first session logs immediately, unattributed, regardless of `waitForCustomerUserId(true)`. +- The official docs (`doc/API.md`) do flag both APIs "Android only," but the Dart API surface itself has no runtime warning, assertion, or `Platform.isAndroid` check β€” an integrator who skips the docs and only reads code/dartdoc could easily assume cross-platform parity. +- No test coverage at all for `setCustomerIdAndLogSession`, and no test verifies the delay/release semantics (mocks only assert the method name was invoked, not any ordering or blocking behavior). + +--- + +## Dependencies +```mermaid +flowchart LR + F021["F-021 Β· Delayed Session Start Pending CUID"]:::sdkCore -->|"iOS: routed to same native handler as"| F015["F-015 Β· Customer User ID (CUID)"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-022-push-notification-deep-link-path-config.md b/internal-docs/features/F-022-push-notification-deep-link-path-config.md new file mode 100644 index 00000000..1cd63e82 --- /dev/null +++ b/internal-docs/features/F-022-push-notification-deep-link-path-config.md @@ -0,0 +1,71 @@ +--- +id: F-022 +name: Push Notification Deep-Link Path Config +type: deepLinking +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-037"] +--- + +## Business Purpose +Push-notification re-engagement campaigns often embed a OneLink URL somewhere inside a custom, nested JSON payload rather than in a fixed top-level field β€” the exact location varies per app. `addPushNotificationDeepLinkPath` tells the native AppsFlyer SDK the JSON key-path where that OneLink URL lives, so the SDK can extract and resolve it as a deep link when the push payload is later handed to it. Without configuring this path, the SDK has no way to find the OneLink URL inside an arbitrarily-shaped push payload, and push-driven deep links silently fail to route users to the right in-app destination. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called once by the host app during startup configuration, **before** `initSdk()`/`startSDK()` is invoked β€” per `doc/API.md`, calling it after SDK start is unsupported. This registers the path so it's in place before any push payload is later delivered (see F-031). + +--- + +## Call Chain +``` +AppsflyerSdk.addPushNotificationDeepLinkPath(List deeplinkPath) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("addPushNotificationDeepLinkPath", deeplinkPath) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("addPushNotificationDeepLinkPath") β†’ addPushNotificationDeepLinkPath(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath(String[] path) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("addPushNotificationDeepLinkPath") β†’ addPushNotificationDeepLinkPath:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] addPushNotificationDeepLinkPath:deeplinkPath] +``` +The configured path is later consulted when a push payload reaches the native SDK (Android: automatically, from the launch/new intent extras; iOS: when `sendPushNotificationData`/`handlePushNotification` is called β€” see F-031), and any OneLink URL found at that path is resolved and delivered through the UDL `onDeepLinking` callback (F-037). + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `addPushNotificationDeepLinkPath(List)` β€” passes the path array directly as method-channel arguments (no wrapping map) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `addPushNotificationDeepLinkPath(call, result)` β€” casts arguments to `ArrayList`, converts to `String[]`, forwards to `AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `addPushNotificationDeepLinkPath:result:` β€” forwards the `NSArray` directly to `[AppsFlyerLib shared]` if non-nil | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `deeplinkPath` (`List`) β€” ordered JSON keys describing where in the push payload the OneLink URL is nested (e.g. `["deeply", "nested", "deep_link"]`) | +| **Output** | `void` β€” fire-and-forget; both native handlers call `result.success(null)`/`result(nil)` unconditionally (Android does so even if `call.arguments` is null, since the `if` guard just skips the native call but still succeeds). | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `addPushNotificationDeepLinkPath`. + +--- + +## Known Limitations +- Must be called before SDK init/start per documentation; neither native handler nor the Dart method enforces or warns about ordering β€” calling it late is a silent no-op for that launch. +- On Android this path config is sufficient on its own (the SDK auto-extracts from intent extras); on iOS it configures the path but does nothing until the payload is separately forwarded to the SDK via F-031's `sendPushNotificationData`/`handlePushNotification` β€” an integrator who configures the path on iOS but skips that step will see push deep links silently fail to resolve. +- No validation of the path array shape (e.g. empty list, non-string elements) on either platform before forwarding to native code. + +--- + +## Dependencies +```mermaid +flowchart LR + F022["F-022 Β· Push Notification Deep-Link Path Config"]:::deepLinking -->|"resolved OneLink URL is delivered via"| F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking + F022 -->|"iOS requires payload forwarded via"| F031["F-031 Β· Push Notification Data Handling"]:::deepLinking + classDef deepLinking fill:#E64980,color:#fff +``` diff --git a/internal-docs/features/F-023-in-app-purchase-validation-v1.md b/internal-docs/features/F-023-in-app-purchase-validation-v1.md new file mode 100644 index 00000000..34a452e7 --- /dev/null +++ b/internal-docs/features/F-023-in-app-purchase-validation-v1.md @@ -0,0 +1,86 @@ +--- +id: F-023 +name: In-App Purchase Validation V1 (Android/iOS separate APIs) +type: purchaseValidation +platform: both +status: deprecated +last_verified: 2026-07-15 +depends_on: ["F-038", "F-025"] +--- + +## Business Purpose +Before the cross-platform V2 API existed, apps needed a way to send a purchase receipt directly to AppsFlyer's validation servers so that in-app-purchase revenue could be confirmed against the store (Google Play / App Store) rather than trusted at face value from the client. This is what lets AppsFlyer distinguish real, store-verified revenue from spoofed or refunded purchases in attribution and revenue reporting. `validateAndLogInAppAndroidPurchase` submits the Google Play `purchaseData`/`signature`/`publicKey` triple; `validateAndLogInAppIosPurchase` submits the App Store `productIdentifier`/`transactionId`. Both are now `@Deprecated` in favor of `validateAndLogInAppPurchaseV2` (F-024), but any app still calling them relies on this exact code path β€” removing it would break revenue validation for apps that have not migrated, with no automatic fallback. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app immediately after it detects a completed purchase from the platform store (Google Play Billing on Android, StoreKit on iOS) and wants that purchase validated and logged to AppsFlyer. + +--- + +## Call Chain +``` +Android: +AppsflyerSdk.validateAndLogInAppAndroidPurchase(publicKey, signature, purchaseData, price, currency, additionalParameters) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("validateAndLogInAppAndroidPurchase", {publicKey, signature, purchaseData, price, currency, additionalParameters}) + β†’ AppsflyerSdkPlugin.onMethodCall case "validateAndLogInAppAndroidPurchase" β†’ validateAndLogInAppPurchase(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ registerValidatorListener() // registers AppsFlyerInAppPurchaseValidatorListener (feeds F-038) + β†’ AppsFlyerLib.getInstance().validateAndLogInAppPurchase(mContext, publicKey, signature, purchaseData, price, currency, additionalParameters) + β†’ result.success(null) // Future resolves immediately; real result arrives later via the "validatePurchase" callback (F-038) + +iOS: +AppsflyerSdk.validateAndLogInAppIosPurchase(productIdentifier, price, currency, transactionId, additionalParameters) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("validateAndLogInAppIosPurchase", {productIdentifier, price, currency, transactionId, additionalParameters}) + β†’ AppsflyerSdkPlugin.handleMethodCall case "validateAndLogInAppIosPurchase" β†’ validateAndLogInAppPurchase:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerLib shared] validateAndLogInAppPurchase:productIdentifier price:currency:transactionId:additionalParameters:success:failure: + β†’ success block β†’ onValidateSuccess: β†’ [_streamHandler sendResponseToFlutter:@"validatePurchase" status:@"success" data:response] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m] + β†’ failure block β†’ onValidateFail: β†’ [_streamHandler sendResponseToFlutter:@"validatePurchase" status:@"failure" data:errorObject] + β†’ result(nil) // Future resolves immediately, same fire-and-forget pattern as Android +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `validateAndLogInAppAndroidPurchase(...)` and `validateAndLogInAppIosPurchase(...)`, both annotated `@Deprecated` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `validateAndLogInAppPurchase(MethodCall, Result)` native handler; calls `registerValidatorListener()` and `AppsFlyerLib.getInstance().validateAndLogInAppPurchase(...)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `validateAndLogInAppPurchase:result:` native handler; calls `[AppsFlyerLib shared] validateAndLogInAppPurchase:...]` with success/failure blocks routed through `onValidateSuccess:`/`onValidateFail:` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `sendResponseToFlutter:status:data:` β€” forwards the async iOS validation result to Dart over the callback `MethodChannel` (`callbacks`) using `invokeMethod("callListener", ...)`, despite the class name suggesting an `EventChannel` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | Android: `publicKey` (String), `signature` (String), `purchaseData` (String), `price` (String), `currency` (String), `additionalParameters` (Map?). iOS: `productIdentifier` (String), `price` (String), `currency` (String), `transactionId` (String), `additionalParameters` (Map). | +| **Output** | The Dart `Future` returned by both methods resolves to `null` immediately (fire-and-forget) β€” it does **not** carry the validation result. The actual validation outcome (success/failure + response payload) is delivered asynchronously, out-of-band, through the `onPurchaseValidation` callback listener (F-038), keyed by callback id `"validatePurchase"`. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” covers `validateAndLogInAppAndroidPurchase` only (asserts the method name string `"validateAndLogInAppAndroidPurchase"` and that `publicKey`/`price`/`currency` are forwarded correctly in the arguments map). No test exists for `validateAndLogInAppIosPurchase`. + +--- + +## Known Limitations +- Both APIs are `@Deprecated` with a doc comment pointing to `validateAndLogInAppPurchaseV2` (F-024), and are marked for removal in a future version β€” new integrations should not use them. +- The Dart `Future` resolves to `null` on both platforms as soon as the native call is dispatched, not when validation actually completes β€” callers cannot `await` a result from these methods; they must separately register `onPurchaseValidation` (F-038) to observe the outcome. This asynchronous split is easy to miss and is not documented in the dartdoc for either method. +- iOS delivers its result via `AppsFlyerStreamHandler.sendResponseToFlutter`, which despite its name and the class being wired to a `FlutterEventChannel`, actually pushes data through the callback `MethodChannel` (`callListener`) instead of an `EventSink` β€” the Dart-side `EventChannel` (`af-events`) instantiated in `appsflyer_sdk.dart` is never `.listen()`-ed to anywhere in `lib/`. +- No test coverage at all for the iOS path (`validateAndLogInAppIosPurchase`), only the Android path is asserted in `test/appsflyer_sdk_test.dart`. +- On Android, the validated result is only forwarded to Dart if `onPurchaseValidation` was registered *before* the validation completes (gated by the `validatePurchaseCallback` boolean flag); on iOS, `sendResponseToFlutter` has no such gate and always attempts to forward, which is an asymmetry between the two native implementations of the same nominal feature. + +--- + +## Dependencies +```mermaid +flowchart LR + F023["F-023 Β· In-App Purchase Validation V1"]:::purchaseValidation + F038["F-038 Β· Legacy Purchase-Validation Notification Callback"]:::purchaseValidation + F025["F-025 Β· iOS Receipt Validation Sandbox Toggle"]:::purchaseValidation + F023 -->|"delivers async result via"| F038 + F023 -->|"iOS: validates against endpoint set by"| F025 + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-024-in-app-purchase-validation-v2.md b/internal-docs/features/F-024-in-app-purchase-validation-v2.md new file mode 100644 index 00000000..bfc6ed29 --- /dev/null +++ b/internal-docs/features/F-024-in-app-purchase-validation-v2.md @@ -0,0 +1,83 @@ +--- +id: F-024 +name: In-App Purchase Validation V2 (cross-platform) +type: purchaseValidation +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-025"] +--- + +## Business Purpose +`validateAndLogInAppPurchaseV2` replaces the deprecated, platform-specific V1 APIs (F-023) with a single cross-platform entry point built around the `AFPurchaseDetails` model, so app developers write one call site instead of branching on `Platform.isAndroid`/`Platform.isIOS`. It lets AppsFlyer verify purchase/subscription revenue against the store (Google Play or App Store) and, unlike V1, returns the actual validation result (or a structured error) directly on the `Future`, so the app can react to a failed validation (e.g. refuse to unlock content) at the call site instead of wiring a separate global listener. Without this feature, apps would have to fall back to the deprecated, harder-to-use, fire-and-forget V1 APIs to get server-side purchase validation at all. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app after it detects a completed purchase or subscription renewal from the platform store, whenever it wants a synchronous (awaited) validation result back from AppsFlyer. + +--- + +## Call Chain +``` +AppsflyerSdk.validateAndLogInAppPurchaseV2(purchaseDetails, {additionalParameters}) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("validateAndLogInAppPurchaseV2", { + 'purchaseDetails': purchaseDetails.toMap(), // {purchaseType, purchaseToken, productId} [lib/src/af_purchase_details.dart] + 'additionalParameters': additionalParameters, + }) + β†’ Android: AppsflyerSdkPlugin.onMethodCall case "validateAndLogInAppPurchaseV2" β†’ validateAndLogInAppPurchaseV2(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ mapPurchaseType(purchaseTypeString) // "subscription" β†’ AFPurchaseType.SUBSCRIPTION, "one_time_purchase" β†’ AFPurchaseType.ONE_TIME_PURCHASE + β†’ new AFPurchaseDetails(purchaseType, purchaseToken, productId) + β†’ AppsFlyerLib.getInstance().validateAndLogInAppPurchase(purchaseDetails, additionalParameters, AppsFlyerInAppPurchaseValidationCallback) + β†’ onInAppPurchaseValidationFinished(...) β†’ result.success(flutterResult) + β†’ onInAppPurchaseValidationError(...) β†’ result.error("VALIDATION_ERROR", errorMessage, flutterErrorResult) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall case "validateAndLogInAppPurchaseV2" β†’ validateAndLogInAppPurchaseV2:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ maps purchaseType string to AFSDKPurchaseType, purchaseToken β†’ transactionId + β†’ new AFSDKPurchaseDetails(productId, transactionId, purchaseType) + β†’ [AppsFlyerLib shared] validateAndLogInAppPurchase:purchaseAdditionalDetails:completion: + β†’ completion(response, nil) β†’ result(response) + β†’ completion(nil, error) β†’ result([FlutterError code:"VALIDATION_ERROR" ...]) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `validateAndLogInAppPurchaseV2(AFPurchaseDetails, {additionalParameters})` | +| `lib/src/af_purchase_details.dart` | `AFPurchaseDetails` model (`purchaseType`, `purchaseToken`, `productId`) and `AFPurchaseType` enum (`oneTimePurchase`, `subscription`); `toMap()` serializes `purchaseType` to `"one_time_purchase"` / `"subscription"` strings for the channel | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `validateAndLogInAppPurchaseV2(MethodCall, Result)` handler; `mapPurchaseType(String)` translates the Dart string enum to the native `AFPurchaseType` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `validateAndLogInAppPurchaseV2:result:` handler; inline string comparison maps to `AFSDKPurchaseType` (note: `purchaseToken` from Dart is passed as iOS `transactionId`) | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `purchaseDetails` (`AFPurchaseDetails` β†’ map with `purchaseType` string, `purchaseToken`, `productId`), `additionalParameters` (`Map?`, optional). | +| **Output** | `Future>` β€” resolves with the native SDK's validation-finished result map on success; on failure the platform channel throws (Android: `PlatformException` with code `"VALIDATION_ERROR"` or `"INVALID_ARGUMENTS"`/`"INVALID_PURCHASE_TYPE"`; iOS: `PlatformException` with code `"VALIDATION_ERROR"` or `"INVALID_ARGUMENTS"`, details include `error_code`/`error_domain`). Unlike V1 (F-023), the result is delivered synchronously on the same `Future` β€” no separate listener is needed. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not register a mock handler for `"validateAndLogInAppPurchaseV2"` or call `validateAndLogInAppPurchaseV2` anywhere; the only purchase-validation test present covers the deprecated `validateAndLogInAppAndroidPurchase` (F-023). The `example/` app does exercise this method (`example/lib/main_page.dart`, `validatePurchase()` helper), but that is a manual/demo path, not an automated test. + +--- + +## Known Limitations +- No automated test coverage β€” a regression in the `purchaseType` string values (`"one_time_purchase"` / `"subscription"`), which must match exactly across `af_purchase_details.dart`, `AppsflyerSdkPlugin.java`'s `mapPurchaseType`, and the iOS string comparison, would not be caught by CI. +- The field name is inconsistent across platforms: Dart/Android call it `purchaseToken`, but the iOS handler maps that same value onto `transactionId` (`NSString* transactionId = purchaseDetailsMap[@"purchaseToken"];`) β€” functionally correct today, but a naming trap for anyone reading only one side of the bridge. +- Invalid `purchaseType` strings are handled inconsistently in shape: Android returns a distinct `"INVALID_PURCHASE_TYPE"` error code, while iOS silently defaults any non-`"subscription"` string to `AFSDKPurchaseTypeOneTimePurchase` instead of validating and erroring β€” a typo'd purchase type on iOS would silently validate as the wrong purchase type rather than fail loudly. + +--- + +## Dependencies +```mermaid +flowchart LR + F024["F-024 Β· In-App Purchase Validation V2"]:::purchaseValidation + F025["F-025 Β· iOS Receipt Validation Sandbox Toggle"]:::purchaseValidation + F024 -->|"iOS: validates against endpoint set by"| F025 + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-025-ios-receipt-validation-sandbox-toggle.md b/internal-docs/features/F-025-ios-receipt-validation-sandbox-toggle.md new file mode 100644 index 00000000..a5f9c0ec --- /dev/null +++ b/internal-docs/features/F-025-ios-receipt-validation-sandbox-toggle.md @@ -0,0 +1,74 @@ +--- +id: F-025 +name: iOS Receipt Validation Sandbox Toggle +type: purchaseValidation +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Apple's StoreKit sandbox (TestFlight / Xcode debug builds) issues receipts that Apple's production receipt-validation endpoint rejects, and vice versa. `useReceiptValidationSandbox` lets a host app tell AppsFlyer's native iOS SDK which Apple endpoint to call when it later validates an in-app purchase (F-023 V1 iOS path, or F-024 V2), so QA/TestFlight builds can validate sandbox receipts without those calls failing against the production endpoint. Without this toggle, developers testing purchase validation on non-production builds would see every validation call fail against Apple's servers, even though the purchase itself is legitimate in the sandbox. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during setup/configuration (typically before or alongside SDK init), whenever it needs to toggle whether subsequent iOS purchase-validation calls (F-023, F-024) hit Apple's sandbox or production receipt-validation environment. + +--- + +## Call Chain +``` +AppsflyerSdk.useReceiptValidationSandbox(bool isSandboxEnabled) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("useReceiptValidationSandbox", isSandboxEnabled) + β†’ AppsflyerSdkPlugin.handleMethodCall case "useReceiptValidationSandbox" + β†’ useReceiptValidationSandbox:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ _isSandboxEnabled = isSandboxEnabled.boolValue + β†’ [AppsFlyerLib shared].useReceiptValidationSandbox = _isSandboxEnabled + β†’ result(nil) +``` +There is no Android implementation: the method channel argument is only handled on the iOS side. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `useReceiptValidationSandbox(bool isSandboxEnabled)` β€” sends the raw bool as the method-call argument (not wrapped in a map) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `useReceiptValidationSandbox:result:` (line ~410) β€” guards with `isKindOfClass:[NSNumber class]`, stores into static `_isSandboxEnabled`, and forwards to `[AppsFlyerLib shared].useReceiptValidationSandbox` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `isSandboxEnabled` (`bool`) β€” sent as the bare method-call argument, not nested in a map. | +| **Output** | None β€” `void` method; native side calls `result(nil)` and the call is fire-and-forget. The effect is purely a stateful flag on `AppsFlyerLib` that changes the behavior of subsequent `validateAndLogInAppPurchase`/`validateAndLogInAppPurchaseV2` calls (F-023, F-024). | + +--- + +## Tests +No dedicated test found. `grep` of `test/` and `example/` for `useReceiptValidationSandbox`/`isSandboxEnabled` returns no matches β€” neither an automated test nor the example app exercises this API. + +--- + +## Known Limitations +- iOS-only: there is no Android method-channel handler or native equivalent for `useReceiptValidationSandbox`. Calling it on Android is a silent no-op from the Dart side (the platform channel simply has nothing registered to receive it on the Android plugin, since Android doesn't implement this case), which is undocumented in the dartdoc (`/// set sandbox for iOS purchase validation` is the only hint). +- No automated or example-app coverage β€” a regression that stops forwarding the flag to `[AppsFlyerLib shared].useReceiptValidationSandbox` would not be caught by CI. +- The static `_isSandboxEnabled` variable is process-global (`static BOOL`), matching the plugin's existing pattern for other boolean toggles (e.g. `disableSKAdNetwork`), but means the flag persists across plugin instances within the same process. + +--- + +## Dependencies +```mermaid +flowchart LR + F025["F-025 Β· iOS Receipt Validation Sandbox Toggle"]:::purchaseValidation + F023["F-023 Β· In-App Purchase Validation V1"]:::purchaseValidation + F024["F-024 Β· In-App Purchase Validation V2"]:::purchaseValidation + F025 -->|"sets Apple endpoint used by"| F023 + F025 -->|"sets Apple endpoint used by"| F024 + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-026-additional-custom-data.md b/internal-docs/features/F-026-additional-custom-data.md new file mode 100644 index 00000000..6a3be71a --- /dev/null +++ b/internal-docs/features/F-026-additional-custom-data.md @@ -0,0 +1,73 @@ +--- +id: F-026 +name: Additional Custom Data +type: eventsAndRevenue +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Some integrations need to enrich every outbound AppsFlyer SDK request with custom key/value context that doesn't fit any dedicated setter (e.g. app-specific segmentation flags, experiment identifiers, or partner-required metadata) β€” data that then flows into raw data/Pull-Push API exports alongside attribution and event data for downstream analysis. `setAdditionalData` gives the host app a generic escape hatch to attach arbitrary custom data to the SDK's requests. Without it, any custom context not covered by a named AppsFlyer API (customer user ID, currency, etc.) would have no way to travel with the SDK's payload at all. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever it needs to attach custom key/value context to subsequent AppsFlyer SDK requests β€” typically once at startup, but callable at any point. + +--- + +## Call Chain +``` +AppsflyerSdk.setAdditionalData(customData) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setAdditionalData", {'customData': customData}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setAdditionalData") β†’ setAdditionalData(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setAdditionalData((HashMap) customData) + β†’ result.success(null) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setAdditionalData") β†’ setAdditionalData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] setAdditionalData:data] + β†’ result(nil) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setAdditionalData(Map? customData)` β€” platform-agnostic Dart API, `void` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setAdditionalData(MethodCall, Result)` β€” casts the `customData` argument directly to `HashMap` and forwards to `AppsFlyerLib.getInstance().setAdditionalData(...)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setAdditionalData:result:` β€” reads `customData` as an `NSDictionary` and forwards to `[[AppsFlyerLib shared] setAdditionalData:]` | +| `doc/API.md` | Public documentation for `setAdditionalData` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `customData` (`Map?`, nullable) β€” arbitrary key/value pairs | +| **Output** | `void` on the Dart side; both native handlers unconditionally call `result(nil)`/`result.success(null)` regardless of whether `customData` was null, empty, or well-formed | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setAdditionalData call` (line 254) calls `setAdditionalData(null)` and asserts the mocked channel receives the `setAdditionalData` invocation; it only verifies the null-safe dispatch path and does not exercise a populated map, nor either native handler's cast/forward logic. + +--- + +## Known Limitations +- **Unsafe native cast on Android**: `AppsflyerSdkPlugin.java` casts the incoming argument directly to `(HashMap) call.argument("customData")` with no type check β€” if Dart ever sends a `Map` that isn't backed by a `HashMap` (e.g. a different `LinkedHashMap`/immutable map from platform-channel deserialization changes) this would throw a `ClassCastException` uncaught by any try/catch in that method, unlike `logAdRevenue`'s more defensive argument handling in the same file. +- No test coverage for the non-null path (a populated `customData` map) on either the Dart dispatch or native handlers β€” only the `null` case is exercised. +- No documented or enforced key/value shape β€” arbitrary nested values are passed straight through to the native SDK with no serialization validation in this plugin layer; malformed values would only surface as a native SDK-level failure outside this code. +- No API to read back or clear previously set additional data; each call presumably replaces (rather than merges into) the native SDK's stored additional data, but that merge-vs-replace behavior lives entirely in the native `AppsFlyerLib.setAdditionalData` implementation, outside this plugin's code. + +--- + +## Dependencies +```mermaid +flowchart LR + F026["F-026 Β· Additional Custom Data"]:::eventsAndRevenue + classDef eventsAndRevenue fill:#12B886,color:#fff +``` diff --git a/internal-docs/features/F-027-user-invite-link-generation-onelink.md b/internal-docs/features/F-027-user-invite-link-generation-onelink.md new file mode 100644 index 00000000..7c3a1d75 --- /dev/null +++ b/internal-docs/features/F-027-user-invite-link-generation-onelink.md @@ -0,0 +1,83 @@ +--- +id: F-027 +name: User Invite Link Generation (OneLink) +type: oneLinkAndGrowth +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-028", "F-056"] +--- + +## Business Purpose +Referral/invite growth loops (e.g. "invite a friend and get X") need a personalized, attributable deep link that carries the referrer's identity, campaign, and channel so that when the invited user installs the app, AppsFlyer can attribute the install back to the referrer. `generateInviteLink` wraps the native AppsFlyer User-Invite-API (`ShareInviteHelper` / `AppsFlyerShareInviteHelper`) so the Flutter app can build such a OneLink without any native code. Without this feature, apps would have to drop to native platform channels themselves to construct invite links, losing the plugin's cross-platform convenience and the built-in referrer/customParams mapping. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever it needs to hand a user a shareable invite/referral link (e.g. tapping an "Invite Friends" button). Requires a base OneLink ID to already be configured, either at init time (`appInviteOneLink` option, F-056) or at runtime via `setAppInviteOneLinkID` (F-028). + +--- + +## Call Chain +``` +AppsflyerSdk.generateInviteLink(params, success, error) [lib/src/appsflyer_sdk.dart] + β†’ _translateInviteLinkParamsToMap(params) [lib/src/appsflyer_sdk.dart] + β†’ startListening(success, "generateInviteLinkSuccess") [lib/src/callbacks.dart] + β†’ startListening(error, "generateInviteLinkFailure") [lib/src/callbacks.dart] + β†’ _methodChannel.invokeMethod("generateInviteLink", paramsMap) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("generateInviteLink") β†’ generateInviteLink(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ ShareInviteHelper.generateInviteUrl(mContext) β†’ LinkGenerator.generateLink(mContext, listener) (native AppsFlyer Android SDK) + β†’ listener.onResponse(url) / onResponseError(error) β†’ runOnUIThread(...) β†’ mCallbackChannel.invokeMethod("callListener", ...) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("generateInviteLink") β†’ generateInviteLink:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:completionHandler: (native AppsFlyer iOS SDK) + β†’ _streamHandler sendResponseToFlutter:responseID:status:data: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m] + β†’ Dart: callbacks.dart _methodCallHandler("callListener") β†’ _callbacksById["generateInviteLinkSuccess"/"generateInviteLinkFailure"](data) [lib/src/callbacks.dart] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_invite_link_params.dart` | `AppsFlyerInviteLinkParams` β€” Dart model for channel, campaign, referrerName, referrerImageUrl, customerID, baseDeepLink, brandDomain, customParams | +| `lib/src/appsflyer_sdk.dart` | `generateInviteLink()` (public API) and `_translateInviteLinkParamsToMap()` β€” builds the method-channel payload and registers the two callbacks | +| `lib/src/callbacks.dart` | `startListening()` registers the success/failure callback IDs; `_methodCallHandler` dispatches `"callListener"` invocations back to the registered Dart callback | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `generateInviteLink(call, result)` β€” maps arguments onto `LinkGenerator`, invokes the native `ShareInviteHelper`, and forwards the async result via `runOnUIThread` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `generateInviteLink:result:` β€” same mapping onto `AppsFlyerLinkGenerator`, using `AppsFlyerShareInviteHelper` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `sendResponseToFlutter:status:data:` β€” JSON-encodes the callback payload and invokes `"callListener"` on the callback channel | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `AppsFlyerInviteLinkParams?` (all fields optional: `channel`, `campaign`, `referrerName`, `referrerImageUrl`, `customerID`, `baseDeepLink`, `brandDomain`, `customParams`), plus `success` and `error` callback functions | +| **Output** | `generateInviteLink` itself is `void` / fire-and-forget (`result.success(null)` / `result(nil)` resolve immediately, independent of link generation). The actual OneLink URL arrives asynchronously via the callback channel: success delivers `{"userInviteURL": ""}` decoded into `{"status": ..., "payload": {...}}`; failure is meant to deliver `{"error": ""}` but see Known Limitations for platform-specific delivery defects | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check generateInviteLink call` (line 186) only asserts that calling `generateInviteLink(null, success, error)` dispatches the `"generateInviteLink"` method over the mocked channel; it does not exercise the success/failure callback payload shape, `_translateInviteLinkParamsToMap`, or either native implementation. + +--- + +## Known Limitations +- **Android failure path likely crashes with `ClassCastException`**: in `AppsflyerSdkPlugin.java`, `LinkGenerator.ResponseListener.onResponseError(String error)` builds a `JSONObject obj` (`obj.put("error", error)`) but then calls `runOnUIThread(error, "generateInviteLinkFailure", AF_FAILURE)` passing the raw `error` `String` instead of `obj`. `runOnUIThread` unconditionally casts non-UDL payloads with `JSONObject dataJSON = (JSONObject) data;`, which throws when `data` is a `String`. This means any real invite-link-generation failure on Android is likely to throw inside a posted `Runnable` on the UI thread rather than deliver the intended `{"error": ...}` payload to Dart. +- **Success/failure callback shapes are inconsistent in Dart**: `lib/src/callbacks.dart`'s `_methodCallHandler` special-cases `"generateInviteLinkSuccess"` (JSON-decodes `data` and wraps it as `{"status": ..., "payload": ...}`), but `"generateInviteLinkFailure"` is not in that case list, so it falls into the `default` branch and delivers the raw (still JSON-encoded, undecoded) string to the `error` callback β€” callers must handle two different payload shapes for the same feature's two callbacks. +- **No validation that a OneLink ID is configured**: `generateInviteLink` does not check whether `setAppInviteOneLinkID` (F-028) or the `appInviteOneLink` init option (F-056) has been set before invoking the native link generator; behavior in that case is left entirely to the native AppsFlyer SDK. +- The Dart method is `void`, not awaitable β€” callers cannot `await` the actual link; they must rely on the `success`/`error` callback functions registered via the shared `startListening` callback-channel mechanism. + +--- + +## Dependencies +```mermaid +flowchart LR + F027["F-027 Β· User Invite Link Generation (OneLink)"]:::oneLinkAndGrowth + F028["F-028 Β· App Invite OneLink ID Configuration"]:::oneLinkAndGrowth + F056["F-056 Β· App Invite Link OneLink ID (init-time)"]:::oneLinkAndGrowth + F028 -->|"provides base OneLink ID"| F027 + F056 -->|"provides base OneLink ID"| F027 + classDef oneLinkAndGrowth fill:#7048E8,color:#fff +``` diff --git a/internal-docs/features/F-028-app-invite-onelink-id-configuration.md b/internal-docs/features/F-028-app-invite-onelink-id-configuration.md new file mode 100644 index 00000000..64312d70 --- /dev/null +++ b/internal-docs/features/F-028-app-invite-onelink-id-configuration.md @@ -0,0 +1,75 @@ +--- +id: F-028 +name: App Invite OneLink ID Configuration +type: oneLinkAndGrowth +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-056"] +--- + +## Business Purpose +The User-Invite-API (F-027) needs to know which OneLink template/ID to base generated invite links on. `setAppInviteOneLinkID` lets the host app set (or change) that base OneLink ID at runtime, independent of SDK initialization β€” useful for apps that resolve the correct OneLink ID dynamically (e.g. per region, per experiment, or fetched from a remote config) after the SDK has already started. Without it, invite links generated via `generateInviteLink` would have no base link to attach referrer metadata to, and the referral/growth loop would not function. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called explicitly by the host app at any point after SDK initialization, typically before the first call to `generateInviteLink` (F-027), whenever the app determines (or changes) which OneLink ID should back invite links. + +--- + +## Call Chain +``` +AppsflyerSdk.setAppInviteOneLinkID(oneLinkID, callback) [lib/src/appsflyer_sdk.dart] + β†’ startListening(callback, "setAppInviteOneLinkIDCallback") [lib/src/callbacks.dart] + β†’ _methodChannel.invokeMethod("setAppInviteOneLinkID", {'oneLinkID': oneLinkID}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setAppInviteOneLinkID") β†’ setAppInivteOneLinkID(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setAppInviteOneLink(oneLinkId) β†’ runOnUIThread(..., "setAppInviteOneLinkIDCallback", AF_SUCCESS) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setAppInviteOneLinkID") β†’ setAppInviteOneLinkID:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerLib shared].appInviteOneLinkID = oneLinkID β†’ _streamHandler sendResponseToFlutter:... + β†’ Dart: callbacks.dart _methodCallHandler("callListener") β†’ _callbacksById["setAppInviteOneLinkIDCallback"](data) [lib/src/callbacks.dart] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setAppInviteOneLinkID(String, Function)` β€” public API; registers the callback and invokes the method channel | +| `lib/src/callbacks.dart` | `startListening()` / `_methodCallHandler` β€” generic callback-channel plumbing shared with other async APIs | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setAppInivteOneLinkID(call, result)` (note the native method's typo β€” "Inivte") β€” forwards to `AppsFlyerLib.getInstance().setAppInviteOneLink(oneLinkId)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setAppInviteOneLinkID:result:` β€” sets `[AppsFlyerLib shared].appInviteOneLinkID` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `oneLinkID` (`String`), `callback` (`Function`) invoked with the async result | +| **Output** | Android: if `oneLinkID` is `null` or empty, `result.success(null)` is returned and the native setter is **not** called (no error surfaced); otherwise the native SDK's OneLink ID is updated and, if a callback was registered, `{"status": "success"}` is delivered via the callback channel. iOS: always sets `appInviteOneLinkID` (even if `nil`/empty) and, if a callback was registered, delivers `{"status": "success"}`. Neither native call ever reports failure β€” the callback fires only on success. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setAppInviteOneLinkID call` (line 180) only asserts that `setAppInviteOneLinkID("oneLinkID", (msg) {})` dispatches the `"setAppInviteOneLinkID"` method over the mocked channel; it does not assert the `oneLinkID` argument's value, the callback payload, or exercise either native implementation. + +--- + +## Known Limitations +- **Android silently no-ops on empty/null `oneLinkID`**: `setAppInivteOneLinkID` in `AppsflyerSdkPlugin.java` checks `if (oneLinkId == null || oneLinkId.length() == 0)` and simply calls `result.success(null)` without setting anything or notifying any registered callback β€” the host app has no way to detect that the OneLink ID was not actually applied. +- **iOS has no equivalent empty-string guard**: `setAppInviteOneLinkID:result:` on iOS assigns `oneLinkID` to `appInviteOneLinkID` unconditionally, so passing an empty string behaves differently across platforms (Android ignores it, iOS sets it). +- **No failure callback path exists on either platform** β€” the registered callback (mapped to `"setAppInviteOneLinkIDCallback"`) is only ever invoked with a success payload; there is no way to be notified of a rejected/invalid OneLink ID from the native SDK. +- Native Android method name (`setAppInivteOneLinkID`) contains a typo, though this is internal and does not affect the public Dart API or the method-channel string name. + +--- + +## Dependencies +```mermaid +flowchart LR + F028["F-028 Β· App Invite OneLink ID Configuration"]:::oneLinkAndGrowth + F056["F-056 Β· App Invite Link OneLink ID (init-time)"]:::oneLinkAndGrowth + F028 -->|"shares same native OneLink-ID property, last write wins"| F056 + classDef oneLinkAndGrowth fill:#7048E8,color:#fff +``` diff --git a/internal-docs/features/F-029-cross-promotion-impression-click-tracking.md b/internal-docs/features/F-029-cross-promotion-impression-click-tracking.md new file mode 100644 index 00000000..990bcfb8 --- /dev/null +++ b/internal-docs/features/F-029-cross-promotion-impression-click-tracking.md @@ -0,0 +1,79 @@ +--- +id: F-029 +name: Cross-Promotion Impression/Click Tracking +type: oneLinkAndGrowth +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-027"] +--- + +## Business Purpose +Advertisers who own multiple apps often promote one app from within another (cross-promotion). To measure whether these in-house house-ads actually drive installs, AppsFlyer needs to see both the impression (ad shown) and the click-to-store-open event, attributed to the promoted app's own AppsFlyer app ID and campaign. `logCrossPromotionImpression` and `logCrossPromotionAndOpenStore` wrap the native `CrossPromotionHelper` / `AppsFlyerCrossPromotionHelper` APIs so this measurement and (on Android) the store-open action can be triggered from Dart. Without this, cross-promotion campaigns between an advertiser's own apps would have no attribution signal distinguishing them from ordinary organic or paid installs. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +- `logCrossPromotionImpression`: called by the host app whenever a house-ad for another of the advertiser's apps is displayed to the user. +- `logCrossPromotionAndOpenStore`: called by the host app when the user taps/clicks that house-ad, to log the click and send the user to the promoted app's store listing. + +--- + +## Call Chain +``` +AppsflyerSdk.logCrossPromotionImpression(appId, campaign, data) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("logCrossPromotionImpression", {...}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("logCrossPromotionImpression") β†’ logCrossPromotionImpression(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ CrossPromotionHelper.logCrossPromoteImpression(mContext, appId, campaign, data) β†’ result.success(null) (native AppsFlyer Android SDK) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("logCrossPromotionImpression") β†’ logCrossPromotionImpression:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerCrossPromotionHelper logCrossPromoteImpression:appId campaign:campaign parameters:parameters] (native AppsFlyer iOS SDK) + +AppsflyerSdk.logCrossPromotionAndOpenStore(appId, campaign, params) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("logCrossPromotionAndOpenStore", {...}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("logCrossPromotionAndOpenStore") β†’ logCrossPromotionAndOpenStore(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ CrossPromotionHelper.logAndOpenStore(mContext, appId, campaign, data) β†’ result.success(null) (native AppsFlyer Android SDK) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("logCrossPromotionAndOpenStore") β†’ logCrossPromotionAndOpenStore:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:completionHandler: β†’ [[UIApplication sharedApplication] openURL:...] (see Known Limitations) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `logCrossPromotionImpression()` and `logCrossPromotionAndOpenStore()` β€” public API, both `void`/fire-and-forget | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logCrossPromotionImpression(call, result)` and `logCrossPromotionAndOpenStore(call, result)` β€” forward to native `CrossPromotionHelper`, guarded by a non-empty `appId` check, always call `result.success(null)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logCrossPromotionImpression:result:` and `logCrossPromotionAndOpenStore:result:` β€” see Known Limitations for behavioral divergence from Android | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `logCrossPromotionImpression(String appId, String campaign, Map? data)`; `logCrossPromotionAndOpenStore(String appId, String campaign, Map? params)` | +| **Output** | Android: `void`, always resolves the method-channel `Future` via `result.success(null)`. iOS: `void`, but see Known Limitations β€” the channel `Future` is never resolved. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check logCrossPromotionAndOpenStore call` (line 165) asserts `appId`/`campaign`/`params` are passed through to the channel correctly; `check logCrossPromotionImpression call` (line 174) only asserts the method name is dispatched. Neither test exercises native behavior or the iOS/Android divergence described below. + +--- + +## Known Limitations +- **iOS `logCrossPromotionImpression:result:` and `logCrossPromotionAndOpenStore:result:` never call `result(...)`**: unlike every other handler in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`, these two methods have no `result(nil)` (or any `result` call) at the end. The Dart-side `Future` returned by `_methodChannel.invokeMethod` for these calls is therefore never resolved on iOS β€” callers awaiting it (if any were added later) would hang indefinitely; today both Dart methods are `void` and don't await, so this is currently silent but latent. +- **iOS `logCrossPromotionAndOpenStore:result:` does not use the native cross-promotion "open store" API at all**: instead of calling an equivalent to Android's `CrossPromotionHelper.logAndOpenStore`, it generates a plain invite link via `AppsFlyerShareInviteHelper generateInviteUrlWithLinkGenerator:` (setting only `campaign` and custom params β€” `appId` is read from `call.arguments` on Android but is **never read** on iOS) and then opens that URL with `UIApplication openURL:options:completionHandler:`. This means the promoted app's ID is not passed to the underlying attribution call on iOS, unlike Android. +- Android's `logCrossPromotionImpression`/`logCrossPromotionAndOpenStore` silently skip the native call entirely (but still return success) if `appId` is `null` or `""`. + +--- + +## Dependencies +```mermaid +flowchart LR + F029["F-029 Β· Cross-Promotion Impression/Click Tracking"]:::oneLinkAndGrowth + F027["F-027 Β· User Invite Link Generation (OneLink)"]:::oneLinkAndGrowth + F029 -->|"iOS: reuses same invite-URL generator helper as"| F027 + classDef oneLinkAndGrowth fill:#7048E8,color:#fff +``` diff --git a/internal-docs/features/F-030-custom-branded-onelink-domains.md b/internal-docs/features/F-030-custom-branded-onelink-domains.md new file mode 100644 index 00000000..6723dd5b --- /dev/null +++ b/internal-docs/features/F-030-custom-branded-onelink-domains.md @@ -0,0 +1,69 @@ +--- +id: F-030 +name: Custom/Branded OneLink Domains +type: oneLinkAndGrowth +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Apps that use a custom/branded domain for their OneLinks (instead of the default `*.onelink.me` domain) need the native SDK to recognize those domains as valid AppsFlyer deep-link/OneLink hosts β€” otherwise links on the branded domain would not be resolved/attributed correctly by the SDK when the app is opened via one of them. `setOneLinkCustomDomain` registers the list of branded domains with the native AppsFlyer SDK so it can correctly parse and attribute links served from them. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during setup/configuration, before relying on branded-domain OneLinks being correctly resolved. Not tied to any specific runtime event. + +--- + +## Call Chain +``` +AppsflyerSdk.setOneLinkCustomDomain(brandDomains) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setOneLinkCustomDomain", brandDomains) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setOneLinkCustomDomain") β†’ setOneLinkCustomDomain(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setOneLinkCustomDomain(brandDomainsArray) β†’ result.success(null) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setOneLinkCustomDomain") β†’ setOneLinkCustomDomain:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] setOneLinkCustomDomains:brandDomains] β†’ result(nil) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setOneLinkCustomDomain(List)` β€” public API, passes the list directly as the method-channel arguments (no wrapping map) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setOneLinkCustomDomain(call, result)` β€” casts `call.arguments` to `ArrayList`, converts to `String[]`, forwards to `AppsFlyerLib.getInstance().setOneLinkCustomDomain(...)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setOneLinkCustomDomain:result:` β€” forwards `call.arguments` directly to `[AppsFlyerLib shared] setOneLinkCustomDomains:]` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `brandDomains` (`List`) β€” sent as the raw method-channel argument, not wrapped in a map | +| **Output** | `void` on both platforms; both native handlers call `result` with `null` unconditionally after forwarding to the native SDK, regardless of whether the domain list was valid | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setOneLinkCustomDomain call` (line 157) asserts `setOneLinkCustomDomain(["brandDomains"])` dispatches the `"setOneLinkCustomDomain"` method with a `List` argument containing `"brandDomains"`. Native behavior on either platform is not exercised. + +--- + +## Known Limitations +- Android's cast `(ArrayList) call.arguments` will throw a `ClassCastException` if the platform channel deserializes the Dart `List` as a different concrete `List` implementation; this is untested and relies on Flutter's standard codec producing an `ArrayList`. +- Neither platform validates the domain strings (e.g. well-formed host names) before forwarding them to the native SDK β€” malformed entries are the native SDK's responsibility to reject. +- No callback/confirmation path exists β€” the call is fire-and-forget on both platforms with no way to detect misconfiguration from Dart. + +--- + +## Dependencies +```mermaid +flowchart LR + F030["F-030 Β· Custom/Branded OneLink Domains"]:::oneLinkAndGrowth + classDef oneLinkAndGrowth fill:#7048E8,color:#fff +``` diff --git a/internal-docs/features/F-031-push-notification-data-handling.md b/internal-docs/features/F-031-push-notification-data-handling.md new file mode 100644 index 00000000..01d10958 --- /dev/null +++ b/internal-docs/features/F-031-push-notification-data-handling.md @@ -0,0 +1,78 @@ +--- +id: F-031 +name: Push Notification Data Handling +type: deepLinking +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-022"] +--- + +## Business Purpose +Push-notification re-engagement campaigns need to be measured (so their ROI shows up in AppsFlyer reporting) and, when the payload carries a OneLink URL, routed as a deep link into the right in-app screen. `sendPushNotificationData` hands the raw push payload to the native SDK so it can attribute the re-engagement and, if a deep-link path was configured (F-022), extract and resolve the embedded OneLink URL. Without this, push campaigns cannot be measured for re-engagement and push-embedded deep links never reach the SDK for resolution. The older `setPushNotification(bool)` toggle is deprecated in favor of this data-carrying API. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever a push notification is received or tapped (foreground, background, or β€” via a persisted "pending push" pattern documented in `doc/API.md` β€” after a cold launch from a terminated state), passing the notification's data payload. + +--- + +## Call Chain +``` +AppsflyerSdk.sendPushNotificationData(Map? userInfo) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("sendPushNotificationData", userInfo) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("sendPushNotificationData") β†’ sendPushNotificationData(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ jsonToBundle(pushPayload) β†’ Bundle + β†’ activity.getIntent().putExtras(bundle); activity.setIntent(intent) + β†’ AppsFlyerLib.getInstance().sendPushNotificationData(activity) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("sendPushNotificationData") β†’ sendPushNotificationData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] handlePushNotification:userInfo] + +AppsflyerSdk.setPushNotification(bool isEnabled) [DEPRECATED, use sendPushNotificationData instead] + β†’ _methodChannel.invokeMethod("setPushNotification", isEnabled) + β†’ Android: setPushNotification(call, result) β†’ AppsFlyerLib.getInstance().sendPushNotificationData(activity) [the isEnabled arg itself is never read] + β†’ iOS: setPushNotification:result: β†’ stores `_isPushNotificationEnabled` static BOOL [never read anywhere else in the file] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `sendPushNotificationData(Map?)` (active) and `setPushNotification(bool)` (`@Deprecated`) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `sendPushNotificationData` β€” converts the JSON payload to a `Bundle` via `jsonToBundle`, stuffs it into the current activity's intent extras, then calls `AppsFlyerLib.getInstance().sendPushNotificationData(activity)`; `setPushNotification` β€” ignores its boolean argument and just re-invokes `sendPushNotificationData(activity)` with whatever extras are already on the intent | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `sendPushNotificationData:result:` β€” passes `userInfo` straight to `[AppsFlyerLib shared] handlePushNotification:]`; `setPushNotification:result:` β€” stores an unused static flag | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `userInfo` / `pushPayload` (`Map?`) β€” the push notification's data payload (e.g. FCM/APNs message data) | +| **Output** | `void`. Android: if `pushPayload` is null, the handler logs and returns **without ever calling `result.success`/`result.error`**; if `activity`/`activity.getIntent()` is null, it logs an error message but, again, never calls `result(...)`. iOS: always calls `result(nil)`. Neither platform returns parsed deep-link data directly β€” any resolved OneLink URL is delivered asynchronously via the UDL `onDeepLinking` callback (F-037), gated by the path configured in F-022. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check sendPushNotificationData call` (around line 335) asserts the mocked channel receives `sendPushNotificationData` with the payload map; this exercises only the Dart-to-channel dispatch, not native bundle conversion, intent mutation, or deep-link extraction. No test covers the deprecated `setPushNotification`. + +--- + +## Known Limitations +- **Significant Android/iOS asymmetry**: Android re-derives the push payload by mutating the *current activity's intent* (`putExtras` + `setIntent`) and re-running `sendPushNotificationData(activity)`, which only works if an `activity` and its `intent` are currently available; iOS passes the raw `NSDictionary` payload directly to `handlePushNotification:`, with no intent/activity dependency. The two platforms' failure modes for a "no activity" state are therefore completely different. +- **Silent failure path on Android**: when `pushPayload` is null, or when `activity`/`intent` is null, the native handler returns without ever calling `result.success(null)` or `result.error(...)`. Since the Dart `sendPushNotificationData` is `void` and not awaited, this is invisible to the caller β€” pending method-channel replies are simply never sent, though because Dart doesn't await them this manifests only as silently dropped data rather than a hang. +- **Deprecated `setPushNotification` behaves differently per platform**: on Android it *actively* re-sends whatever is already in the intent extras to the native SDK regardless of the `isEnabled` value passed in (the argument is read from the channel but never inspected); on iOS it only stores an internal flag (`_isPushNotificationEnabled`) that is never read anywhere else in `AppsflyerSdkPlugin.m` β€” so on iOS, calling the deprecated API has no observable effect on the native SDK at all. +- The iOS "MUST also call `sendPushNotificationData`" requirement for OneLink-URL-in-push deep linking (per `doc/API.md`) is not enforced anywhere in code β€” an integrator who configures `addPushNotificationDeepLinkPath` (F-022) but skips this call on iOS gets no deep-link resolution and no error signal. + +--- + +## Dependencies +```mermaid +flowchart LR + F031["F-031 Β· Push Notification Data Handling"]:::deepLinking -->|"requires deep-link key-path from"| F022["F-022 Β· Push Notification Deep-Link Path Config"]:::deepLinking + F031 -->|"resolved OneLink URL surfaces via"| F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking + classDef deepLinking fill:#E64980,color:#fff +``` diff --git a/internal-docs/features/F-032-facebook-deferred-app-links.md b/internal-docs/features/F-032-facebook-deferred-app-links.md new file mode 100644 index 00000000..cbee6a4f --- /dev/null +++ b/internal-docs/features/F-032-facebook-deferred-app-links.md @@ -0,0 +1,69 @@ +--- +id: F-032 +name: Facebook Deferred App Links +type: deepLinking +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Apps that run Facebook Ads alongside AppsFlyer OneLink need deferred deep links to resolve correctly even when Facebook's own SDK has already claimed the deferred-app-link resolution flow. `enableFacebookDeferredApplinks` tells the native AppsFlyer SDK to interoperate with the Facebook SDK's `FBSDKAppLinkUtility` class so both attribution sources can coexist instead of one silently overriding or racing the other. Without enabling this, apps combining Facebook Ads and AppsFlyer OneLink risk deferred deep links resolving incorrectly (or not at all) for users who install after clicking a Facebook ad. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called once by the host app during startup configuration (before/around SDK init), for apps that have integrated the Facebook SDK and want AppsFlyer to interoperate with its deferred app-link resolution. + +--- + +## Call Chain +``` +AppsflyerSdk.enableFacebookDeferredApplinks(bool isEnabled) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("enableFacebookDeferredApplinks", {'isFacebookDeferredApplinksEnabled': isEnabled}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("enableFacebookDeferredApplinks") β†’ enableFacebookDeferredApplinks(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().enableFacebookDeferredApplinks(true|false) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("enableFacebookDeferredApplinks") β†’ enableFacebookDeferredApplinks:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ only if isEnabled == true: [[AppsFlyerLib shared] enableFacebookDeferredApplinksWithClass:NSClassFromString(@"FBSDKAppLinkUtility")] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `enableFacebookDeferredApplinks(bool)` β€” wraps the flag in `{'isFacebookDeferredApplinksEnabled': isEnabled}` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `enableFacebookDeferredApplinks(call, result)` β€” explicitly calls the native API with either `true` or `false` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `enableFacebookDeferredApplinks:result:` β€” only calls the native enabling API when `isEnabled == true`; a `false` value is a no-op | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `isEnabled` (bool) | +| **Output** | `void` β€” fire-and-forget; both handlers always call `result.success(null)`/`result(nil)`. Resolved deferred-link data (if any) is not returned here β€” it surfaces through whichever conversion/attribution channel the app has registered (legacy `onInstallConversionData`/`onAppOpenAttribution`, or UDL `onDeepLinking`), which are native-SDK internal behaviors this plugin does not directly wire to this flag. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check enableFacebookDeferredApplinks call` (around line 342) asserts the mocked channel receives `enableFacebookDeferredApplinks` with `isFacebookDeferredApplinksEnabled: true`. This exercises only the Dart-to-channel dispatch; it does not verify native behavior or the `false` no-op path on iOS. + +--- + +## Known Limitations +- **Android/iOS asymmetry on disabling**: Android's handler calls the native API with the literal `isEnabled` value either way, so passing `false` actively disables the feature; iOS's handler only acts on `true` β€” passing `false` is silently ignored, so once enabled on iOS it cannot be turned back off via this API. +- Depends on the Facebook SDK (`FBSDKAppLinkUtility`) being present in the host app; the iOS handler resolves the class dynamically via `NSClassFromString`, so if the Facebook SDK isn't linked, the native AppsFlyer SDK receives a nil class with behavior determined entirely outside this plugin's code (not verified here). +- No signal is returned to Dart indicating whether Facebook deferred-app-link interop actually engaged (e.g. class not found, Facebook SDK version mismatch) β€” this call is purely fire-and-forget configuration. + +--- + +## Dependencies +```mermaid +flowchart LR + F032["F-032 Β· Facebook Deferred App Links"]:::deepLinking + classDef deepLinking fill:#E64980,color:#fff +``` diff --git a/internal-docs/features/F-033-skadnetwork-opt-out.md b/internal-docs/features/F-033-skadnetwork-opt-out.md new file mode 100644 index 00000000..afd797d7 --- /dev/null +++ b/internal-docs/features/F-033-skadnetwork-opt-out.md @@ -0,0 +1,67 @@ +--- +id: F-033 +name: SKAdNetwork Opt-out (iOS) +type: platformIntegration +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Apple's SKAdNetwork is the privacy-preserving attribution framework AppsFlyer's iOS SDK uses automatically post-iOS 14. Some advertisers run their own SKAdNetwork conversion-value scheme, use a different measurement partner for it, or need to suppress AppsFlyer's SKAdNetwork registration/postback handling entirely for compliance or contractual reasons. `disableSKAdNetwork` lets the host app flip that behavior off (the SDK still sends the SKAdNetwork registration request, but AppsFlyer stops returning/acting on conversion-value rules) without disabling the rest of AppsFlyer attribution. Without it, an app that needs to hand SKAdNetwork off to another party would have no supported way to do so short of not integrating the AppsFlyer SDK's SKAdNetwork handling path at all. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during startup configuration, before `AppsFlyerLib` starts, whenever the app wants to opt out of AppsFlyer's automatic SKAdNetwork conversion-value handling on iOS. + +--- + +## Call Chain +``` +AppsflyerSdk.disableSKAdNetwork(isEnabled) [lib/src/appsflyer_sdk.dart:566] + β†’ _methodChannel.invokeMethod("disableSKAdNetwork", isEnabled) + β†’ iOS: AppsflyerSdkPlugin handleMethodCall: case "disableSKAdNetwork" β†’ disableSKAdNetwork:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:153] + β†’ [AppsFlyerLib shared].disableSKAdNetwork = _isSKADEnabled [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:401] +``` +No `case "disableSKAdNetwork"` exists in `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java`'s method-call switch β€” on Android the call falls through to the default branch and returns `MethodNotImplemented`. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `disableSKAdNetwork(bool)` β€” platform-agnostic Dart API surface (no `Platform.isIOS` guard) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `disableSKAdNetwork:result:` native handler, sets `[AppsFlyerLib shared].disableSKAdNetwork` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `isEnabled` (bool) β€” `true` disables AppsFlyer's SKAdNetwork handling; native only applies the change if the argument is an `NSNumber` (boolean), otherwise silently no-ops. | +| **Output** | `void` β€” fire-and-forget; native always calls `result(nil)` regardless of whether the value was applied. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check disableSKAdNetwork call` (around line 349) asserts the mocked channel receives the method name `disableSKAdNetwork` with the boolean argument. The Dart test harness cannot verify the native iOS assignment to `AppsFlyerLib.shared.disableSKAdNetwork` actually takes effect. + +--- + +## Known Limitations +- **iOS-only**: no Android implementation exists (concept doesn't apply β€” SKAdNetwork is an Apple/iOS-specific framework). The Dart API has no `Platform.isIOS` guard, so calling it on Android silently fails with `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer rather than a documented no-op. +- Native code silently ignores non-boolean arguments (`isKindOfClass:[NSNumber class]` check) instead of surfacing an error to the caller, which can mask integration mistakes. +- Disabling SKAdNetwork handling here does not stop iOS from sending the registration call itself (`registerAppForAdNetworkAttribution`/`updateConversionValue` are OS-level, not AppsFlyer-level) β€” it only stops AppsFlyer's SDK-side processing of it. + +--- + +## Dependencies +```mermaid +flowchart LR + F033["F-033 Β· SKAdNetwork Opt-out (iOS)"]:::platformIntegration + classDef platformIntegration fill:#495057,color:#fff +``` diff --git a/internal-docs/features/F-034-advertising-identifier-collection-disable.md b/internal-docs/features/F-034-advertising-identifier-collection-disable.md new file mode 100644 index 00000000..53e80cfd --- /dev/null +++ b/internal-docs/features/F-034-advertising-identifier-collection-disable.md @@ -0,0 +1,82 @@ +--- +id: F-034 +name: Advertising Identifier Collection Disable +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Privacy regulations (GDPR, CCPA) and platform policy changes increasingly require apps to be able to fully opt out of collecting device advertising identifiers (GAID/AAID/OAID on Android, IDFA on iOS) rather than just anonymizing individual users. `setDisableAdvertisingIdentifiers` gives the host app a single cross-platform switch for this, usable both as a one-time init-time option and as a runtime toggle. Without it, an app could not comply with a user's advertising-ID opt-out request without disabling the SDK entirely (F-017). + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Two distinct trigger points exist: (1) at SDK init time, via the `disableAdvertisingIdentifier` field on `AppsFlyerOptions`/init map, applied once during `initSdk()`; (2) at any later point, via the standalone `setDisableAdvertisingIdentifiers(bool)` runtime method. + +--- + +## Call Chain +``` +# Init-time path +AppsflyerSdk._validateAFOptions / _validateMapOptions [lib/src/appsflyer_sdk.dart] + β†’ validatedOptions[DISABLE_ADVERTISING_IDENTIFIER] = options.disableAdvertisingIdentifier ?? false + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: AppsflyerSdkPlugin.initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ if (advertiserIdDisabled) instance.setDisableAdvertisingIdentifiers(true) [only applies `true`; never explicitly re-enables] + β†’ iOS: AppsflyerSdkPlugin.initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ resolves selector `setDisableAdvertisingIdentifier:` via objc_msgSend runtime dispatch, only if disableAdvertisingIdentifier == true + +# Runtime path +AppsflyerSdk.setDisableAdvertisingIdentifiers(isEnabled) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setDisableAdvertisingIdentifiers", isEnabled) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setDisableAdvertisingIdentifiers") β†’ setDisableAdvertisingIdentifiers(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setDisableAdvertisingIdentifiers(isEnabled) [handles both true and false explicitly] + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("setDisableAdvertisingIdentifiers") β†’ setDisableAdvertisingIdentifiers:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [AppsFlyerLib shared] setDisableAdvertisingIdentifier:_isAdvertiserIdEnabled] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setDisableAdvertisingIdentifiers(bool)` runtime API; `_validateAFOptions`/`_validateMapOptions` init-time option handling | +| `lib/src/appsflyer_options.dart` | `disableAdvertisingIdentifier` field on `AppsFlyerOptions` | +| `lib/src/appsflyer_constants.dart` | `DISABLE_ADVERTISING_IDENTIFIER` string key | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk` (init-time, line 1072), `setDisableAdvertisingIdentifiers(call, result)` (runtime, line 564) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` (init-time, uses `objc_msgSend` runtime dispatch to `setDisableAdvertisingIdentifier:`, line ~841-855), `setDisableAdvertisingIdentifiers:result:` (runtime, line 380) | +| `doc/BasicIntegration.md` | Documents the field as "Opt-out of the collection of Advertising Identifiers, which include OAID, AAID, GAID and IDFA." | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | Init-time: `disableAdvertisingIdentifier` (bool?, defaults to `false` if unset). Runtime: `isEnabled` (bool) β€” `true` disables collection of GAID/AAID/OAID (Android) or IDFA (iOS). | +| **Output** | `void` β€” fire-and-forget in both paths; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setDisableAdvertisingIdentifiers call` (line 355) asserts the mocked channel receives `'setDisableAdvertisingIdentifiers'` with `capturedArguments == true`. The init-time option path (`disableAdvertisingIdentifier` inside `initSdk`) is not separately asserted β€” the `check initSdk call` test only checks that `'initSdk'` was invoked, not the validated map's contents. + +--- + +## Known Limitations +- **Init-time and runtime paths are asymmetric on Android.** The `initSdk` handler only calls `setDisableAdvertisingIdentifiers(true)` if the flag is `true`; if it's `false` (the default), it does nothing (relies on native SDK default rather than explicitly calling `setDisableAdvertisingIdentifiers(false)`). The standalone runtime method, by contrast, always calls the native API with the exact boolean passed (both `true` and `false` explicitly). +- **iOS init-time path uses Objective-C runtime dispatch (`objc_msgSend` via `NSSelectorFromString`)** instead of calling the SDK method directly, apparently to guard against an SDK version where the selector might not exist (`respondsToSelector:` check). This is inconsistent with the runtime-toggle path (`setDisableAdvertisingIdentifiers:result:`), which calls `[AppsFlyerLib shared] setDisableAdvertisingIdentifier:]` directly β€” a version mismatch between the two could cause the init-time flag to silently no-op while the runtime toggle continues to work (or vice versa). +- No getter exists to read back the current disabled state from Dart. + +--- + +## Dependencies +```mermaid +flowchart LR + F034["F-034 Β· Advertising Identifier Collection Disable"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-035-conversion-data-callback.md b/internal-docs/features/F-035-conversion-data-callback.md new file mode 100644 index 00000000..91d849aa --- /dev/null +++ b/internal-docs/features/F-035-conversion-data-callback.md @@ -0,0 +1,85 @@ +--- +id: F-035 +name: Conversion Data Callback (GCD) +type: deepLinking +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-001"] +--- + +## Business Purpose +When a user installs the app after clicking an attributed link (or organically), the app often needs to know immediately β€” before the user even signs in β€” which campaign drove the install and whether it carries a deferred deep link, so it can personalize the very first session (e.g. show a specific onboarding screen or promo). `onInstallConversionData` ("Get Conversion Data", GCD) is the legacy API that delivers this attribution/conversion payload to Dart right after install. Without it, apps lose the ability to react to install-time attribution data and legacy deferred-deep-link payloads inside the app itself. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Native SDK fires this once conversion data has been fetched from AppsFlyer's servers following an app install/launch β€” gated end-to-end by the `registerConversionDataCallback` flag passed to `initSdk()` (F-001) and by the Dart app having called `onInstallConversionData(callback)` to subscribe before that init. + +--- + +## Call Chain +``` +AppsflyerSdk.initSdk(registerConversionDataCallback: true, ...) [lib/src/appsflyer_sdk.dart] + β†’ validatedOptions[AF_GCD] = registerConversionDataCallback || registerOnAppOpenAttributionCallback + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: initSdk(call, result) β†’ if (getGCD) gcdListener = afConversionListener; instance.init(afDevKey, gcdListener, mContext) [android/.../AppsflyerSdkPlugin.java] + β†’ iOS: initSdkWithCall:result: β†’ if (isConversionData) [[AppsFlyerLib shared] setDelegate:_streamHandler] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + +AppsflyerSdk.onInstallConversionData(Function callback) [lib/src/appsflyer_sdk.dart] + β†’ startListening(callback, "onInstallConversionData") [lib/src/callbacks.dart] + β†’ _channel(AF_CALLBACK_CHANNEL).invokeMethod("startListening", "onInstallConversionData") + β†’ Android: startListening(...) β†’ gcdCallback = true (when callbackName == AF_GCD_CALLBACK == "onInstallConversionData") [android/.../AppsflyerSdkPlugin.java] + β†’ iOS: startListening:result: β†’ _gcdCallback = true (when callbackId == afGCDCallback == "onInstallConversionData") [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + +Native SDK conversion data arrives: + Android: afConversionListener.onConversionDataSuccess(map) / onConversionDataFail(s) + β†’ if (gcdCallback) runOnUIThread(data, AF_GCD_CALLBACK, status) β†’ mCallbackChannel.invokeMethod("callListener", jsonArgs) + iOS: AppsFlyerStreamHandler.onConversionDataSuccess:/onConversionDataFail: β†’ sends JSON via AppsflyerSdkPlugin.callbackChannel "callListener" + β†’ Dart: _methodCallHandler(call) [lib/src/callbacks.dart] β†’ callMap["id"] == "onInstallConversionData" + β†’ _callbacksById["onInstallConversionData"]({"status": ..., "payload": decodedData}) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `onInstallConversionData(Function)` β€” registers the Dart callback via `startListening` | +| `lib/src/callbacks.dart` | `_methodCallHandler` β€” decodes the `callListener` JSON envelope and dispatches `{"status", "payload"}` to the registered `"onInstallConversionData"` callback | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `afConversionListener.onConversionDataSuccess/onConversionDataFail` β€” native `AppsFlyerConversionListener` implementation; `initSdk` registers it with `AppsFlyerLib.getInstance().init(...)` only when `AF_GCD` is true; also caches results (`cachedOnConversionDataSuccess`/`cachedOnConversionDataFail`) across activity detach/reattach (`RD-65582`) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `onConversionDataSuccess:`/`onConversionDataFail:` β€” `AppsFlyerLibDelegate` implementation, gated by `[AppsflyerSdkPlugin gcdCallback]` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β€” sets `_streamHandler` as the `AppsFlyerLib` delegate only if the `GCD` flag is true | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None from Dart beyond registering the callback; the payload itself originates from AppsFlyer's attribution servers via the native SDK. | +| **Output** | `{"status": "success"|"failure", "payload": Map?}` delivered to the Dart callback passed to `onInstallConversionData`. On failure, native code wraps the error string into the same envelope shape (`buildJsonResponse`) rather than a distinct failure structure. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `onInstallConversionData` or the `callListener`/`onInstallConversionData` dispatch path in `lib/src/callbacks.dart`. + +--- + +## Known Limitations +- **Shared registration flag, independent dispatch flags**: `initSdk`'s `AF_GCD`/`GCD` flag is `registerConversionDataCallback || registerOnAppOpenAttributionCallback` β€” enabling *either* flag registers the native conversion listener/delegate for *both* channels (F-035 and F-036 share one native registration). But each channel only actually forwards data to Dart if its own `gcdCallback`/`oaoaCallback` (Android) or `_gcdCallback`/`_oaoaCallback` (iOS) flag was separately flipped by calling `onInstallConversionData`/`onAppOpenAttribution` from Dart. An app that sets only `registerOnAppOpenAttributionCallback: true` but never calls `onInstallConversionData()` will still have the native listener registered but conversion-data events for that channel are simply dropped (Android) or dropped (iOS) rather than queued. +- Documentation (`doc/API.md`) explicitly requires the Dart-side `onInstallConversionData` implementation to be registered **before** SDK initialization; nothing in code enforces or warns about this ordering. +- Android caches at most one conversion-data outcome (success or fail) across an activity-detach window (`RD-65582` static fields); if multiple attach/detach cycles occur before Dart reattaches its listener, only the most recent cached result survives β€” no queueing of multiple missed callbacks. +- Error payloads use the same JSON envelope as success payloads (`buildJsonResponse` wraps the error string as `"data"`), so Dart-side consumers must inspect `status` rather than relying on a distinct shape to detect failure. + +--- + +## Dependencies +```mermaid +flowchart LR + F035["F-035 Β· Conversion Data Callback (GCD)"]:::deepLinking -->|"listener registration gated by GCD flag set in"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef deepLinking fill:#E64980,color:#fff + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-036-app-open-attribution-callback.md b/internal-docs/features/F-036-app-open-attribution-callback.md new file mode 100644 index 00000000..37590cf6 --- /dev/null +++ b/internal-docs/features/F-036-app-open-attribution-callback.md @@ -0,0 +1,85 @@ +--- +id: F-036 +name: App-Open Attribution Callback (OAOA) +type: deepLinking +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-001"] +--- + +## Business Purpose +When an already-installed app is (re)opened via an attributed link β€” e.g. a user taps a OneLink pointing to specific content while the app is already on their device β€” the app needs to know what that link resolved to in order to route the user to the right place. `onAppOpenAttribution` ("On App Open Attribution", OAOA) is the legacy direct-deep-linking API that delivers this attribution payload to Dart. Without it, apps relying on the legacy (pre-UDL) deep-linking model cannot react to attributed app-open events for existing users. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Native SDK fires this when a deep link is clicked by a user who already has the app installed β€” gated end-to-end by the `AF_GCD`/`GCD` flag passed to `initSdk()` (F-001, set true when `registerOnAppOpenAttributionCallback` is requested) and by the Dart app having called `onAppOpenAttribution(callback)` to subscribe before init. Per `doc/API.md`, this callback does **not** fire when the app has migrated to Unified Deep Linking (F-037) β€” the two are mutually exclusive delivery paths for direct deep linking. + +--- + +## Call Chain +``` +AppsflyerSdk.initSdk(registerOnAppOpenAttributionCallback: true, ...) [lib/src/appsflyer_sdk.dart] + β†’ validatedOptions[AF_GCD] = registerConversionDataCallback || registerOnAppOpenAttributionCallback + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: initSdk(call, result) β†’ if (getGCD) gcdListener = afConversionListener; instance.init(afDevKey, gcdListener, mContext) [android/.../AppsflyerSdkPlugin.java] + β†’ iOS: initSdkWithCall:result: β†’ if (isConversionData) [[AppsFlyerLib shared] setDelegate:_streamHandler] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + +AppsflyerSdk.onAppOpenAttribution(Function callback) [lib/src/appsflyer_sdk.dart] + β†’ startListening(callback, "onAppOpenAttribution") [lib/src/callbacks.dart] + β†’ _channel(AF_CALLBACK_CHANNEL).invokeMethod("startListening", "onAppOpenAttribution") + β†’ Android: startListening(...) β†’ oaoaCallback = true (when callbackName == AF_OAOA_CALLBACK == "onAppOpenAttribution") [android/.../AppsflyerSdkPlugin.java] + β†’ iOS: startListening:result: β†’ _oaoaCallback = true (when callbackId == afOAOACallback == "onAppOpenAttribution") [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + +Native SDK app-open attribution arrives: + Android: afConversionListener.onAppOpenAttribution(map) / onAttributionFailure(errorMessage) + β†’ if (oaoaCallback) runOnUIThread(data, AF_OAOA_CALLBACK, status) β†’ mCallbackChannel.invokeMethod("callListener", jsonArgs) + iOS: AppsFlyerStreamHandler.onAppOpenAttribution:/onAppOpenAttributionFailure: β†’ sends JSON via AppsflyerSdkPlugin.callbackChannel "callListener" + β†’ Dart: _methodCallHandler(call) [lib/src/callbacks.dart] β†’ callMap["id"] == "onAppOpenAttribution" + β†’ _callbacksById["onAppOpenAttribution"]({"status": ..., "payload": decodedData}) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `onAppOpenAttribution(Function)` β€” registers the Dart callback via `startListening` | +| `lib/src/callbacks.dart` | `_methodCallHandler` β€” decodes the `callListener` JSON envelope and dispatches `{"status", "payload"}` to the registered `"onAppOpenAttribution"` callback | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `afConversionListener.onAppOpenAttribution/onAttributionFailure` β€” native `AppsFlyerConversionListener` methods, gated by `oaoaCallback`; also cached across activity detach/reattach (`cachedOnAppOpenAttribution`/`cachedOnAttributionFailure`, `RD-65582`) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `onAppOpenAttribution:`/`onAppOpenAttributionFailure:` β€” `AppsFlyerLibDelegate` methods, gated by `[AppsflyerSdkPlugin oaoaCallback]` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β€” sets `_streamHandler` as the `AppsFlyerLib` delegate only if the `GCD` flag is true (shared with F-035) | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None from Dart beyond registering the callback; the payload originates from the native SDK's link-resolution logic. | +| **Output** | `{"status": "success"|"failure", "payload": Map?}` delivered to the Dart callback passed to `onAppOpenAttribution`. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `onAppOpenAttribution` or its dispatch path in `lib/src/callbacks.dart`. + +--- + +## Known Limitations +- **Mutually exclusive with UDL**: per `doc/DeepLink.md`, once an app migrates to Unified Deep Linking, `onAppOpenAttribution` "will not be called" β€” nothing in code enforces this exclusivity or warns an integrator who registers both `registerOnAppOpenAttributionCallback` and `registerOnDeepLinkingCallback` (F-037) simultaneously. +- Shares its native listener/delegate registration with F-035 (both gated by the same combined `AF_GCD`/`GCD` flag) β€” see F-035's Known Limitations for the registration-vs-dispatch flag mismatch this creates. +- Documentation requires the Dart-side `onAppOpenAttribution` implementation to be registered **before** SDK initialization; this ordering is not enforced in code. +- Android caches only the single most recent success or failure outcome across an activity-detach window (`RD-65582`); rapid multiple attribution events during a detach period are not individually queued. + +--- + +## Dependencies +```mermaid +flowchart LR + F036["F-036 Β· App-Open Attribution Callback (OAOA)"]:::deepLinking -->|"listener registration gated by GCD flag set in"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef deepLinking fill:#E64980,color:#fff + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-037-unified-deep-linking-callback-and-models.md b/internal-docs/features/F-037-unified-deep-linking-callback-and-models.md new file mode 100644 index 00000000..25c94177 --- /dev/null +++ b/internal-docs/features/F-037-unified-deep-linking-callback-and-models.md @@ -0,0 +1,97 @@ +--- +id: F-037 +name: Unified Deep Linking (UDL) Callback & Models +type: deepLinking +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-001", "F-039", "F-040"] +--- + +## Business Purpose +Unified Deep Linking is AppsFlyer's current recommended API for both direct and deferred deep linking: a single Dart callback (`onDeepLinking`) delivers one strongly-shaped result (`DeepLinkResult` β€” a `Status`, an optional `Error`, and an optional `DeepLink` payload) regardless of whether the link was clicked while the app was already installed or triggered a deferred install. Without it, integrators would have to juggle the two legacy, loosely-typed callbacks (`onAppOpenAttribution` / `onInstallConversionData`, F-035/F-036) and hand-parse raw maps to build a single personalized-routing experience (e.g. OneLink-driven deep content). + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Native SDK resolves a deep link (direct click while installed, or deferred deep link surfaced after a fresh install) and invokes its UDL delegate/listener β€” gated end-to-end by the `UDL` flag passed to `initSdk(registerOnDeepLinkingCallback: true)` (F-001) and by the Dart app having called `onDeepLinking(callback)` to subscribe before init. The underlying native trigger differs per platform: on Android it is `AppsFlyerLib.getInstance().performOnDeepLinking(...)`, invoked from the plugin's `onNewIntent` forwarding (F-040) as well as the SDK's own `onResume` intent inspection; on iOS it is `[AppsFlyerLib shared] handleOpenUrl:`/`continueUserActivity:`, invoked from the app-delegate/scene entry points buffered by `AppsFlyerAttribution` (F-039). + +--- + +## Call Chain +``` +AppsflyerSdk.initSdk(registerOnDeepLinkingCallback: true, ...) [lib/src/appsflyer_sdk.dart] + β†’ validatedOptions[AF_UDL] = registerOnDeepLinkingCallback + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: initSdk(call, result) β†’ if (getUdl) instance.subscribeForDeepLink(afDeepLinkListener) [android/.../AppsflyerSdkPlugin.java] + β†’ iOS: initSdkWithCall:result: β†’ if (isUDP) [AppsFlyerLib shared].deepLinkDelegate = _streamHandler [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + +AppsflyerSdk.onDeepLinking(Function(DeepLinkResult) callback) [lib/src/appsflyer_sdk.dart] + β†’ startListeningToUDL(callback, "onDeepLinking") [lib/src/callbacks.dart] + β†’ _channel(AF_CALLBACK_CHANNEL).invokeMethod("startListening", "onDeepLinking") + β†’ Android: startListening(...) β†’ udlCallback = true (when callbackName == AF_UDL_CALLBACK == "onDeepLinking") [android/.../AppsflyerSdkPlugin.java] + β†’ iOS: startListening:result: β†’ _udpCallback = true (when callbackId == afUDPCallback == "onDeepLinking") [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + +Native deep link resolved (via F-039 iOS entry points / F-040 Android onNewIntent, or SDK-internal resume/link-resolution): + Android: afDeepLinkListener.onDeepLinking(DeepLinkResult) [com.appsflyer.deeplink.DeepLinkResult, native SDK type] + β†’ if (udlCallback) runOnUIThread(deepLinkResult, AF_UDL_CALLBACK, AF_SUCCESS) + β†’ args {"id", "deepLinkStatus", "deepLinkError"?, "deepLinkObj"? } β†’ mCallbackChannel.invokeMethod("callListener", jsonArgs) + iOS: AppsFlyerStreamHandler.didResolveDeepLink: (AppsFlyerDeepLinkDelegate) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m] + β†’ if ([AppsflyerSdkPlugin udpCallback]) build {"id", "deepLinkStatus", "deepLinkError"?, "deepLinkObj"?} β†’ AppsflyerSdkPlugin.callbackChannel invokeMethod:"callListener" + Dart: _methodCallHandler(call) [lib/src/callbacks.dart] β†’ callMap["id"] == "onDeepLinking" + β†’ error = callMap["deepLinkError"]?.errorFromString() + β†’ status = callMap["deepLinkStatus"]?.statusFromString() ?? Status.PARSE_ERROR + β†’ deepLink = callMap["deepLinkObj"] != null ? DeepLink(map) : null + β†’ _udlCallback!(DeepLinkResult(error, deepLink, status)) [lib/src/udl/deep_link_result.dart, lib/src/udl/deeplink.dart] +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `onDeepLinking(Function(DeepLinkResult))` β€” registers the Dart UDL callback; `initSdk(registerOnDeepLinkingCallback: ...)` sets the `AF_UDL` init flag | +| `lib/src/callbacks.dart` | `startListeningToUDL` β€” stores a single `_udlCallback` (unlike the multi-key `_callbacksById` map used for other callbacks); `_methodCallHandler`'s `"onDeepLinking"` branch parses `deepLinkStatus`/`deepLinkError`/`deepLinkObj` into a `DeepLinkResult` | +| `lib/src/udl/deeplink.dart` | `DeepLink` β€” typed accessors (`deepLinkValue`, `matchType`, `mediaSource`, `campaign`, `afSub1..5`, `isDeferred`, etc.) over the raw click-event map | +| `lib/src/udl/deep_link_result.dart` | `DeepLinkResult`, `Status` (`FOUND`/`NOT_FOUND`/`ERROR`/`PARSE_ERROR`), `Error` (`TIMEOUT`/`NETWORK`/`HTTP_STATUS_CODE`/`UNEXPECTED`/`DEVELOPER_ERROR`) enums and string-conversion extensions used to decode the wire payload | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `afDeepLinkListener` (`com.appsflyer.deeplink.DeepLinkListener`) β€” registered via `AppsFlyerLib.getInstance().subscribeForDeepLink(...)` only when `AF_UDL` is true; `runOnUIThread` serializes `DeepLinkResult` into the `deepLinkStatus`/`deepLinkError`/`deepLinkObj` JSON shape; caches `cachedDeepLinkResult` across activity detach/reattach (`RD-65582`) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `didResolveDeepLink:` (`AppsFlyerDeepLinkDelegate`) β€” gated by `[AppsflyerSdkPlugin udpCallback]`; builds the same JSON shape as Android | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` sets `[AppsFlyerLib shared].deepLinkDelegate = _streamHandler` only if the `UDL` flag is true; `startListening:` flips the internal `_udpCallback` flag when `callbackId == afUDPCallback` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | Defines `afUDL` (`"UDL"`), `afUDPCallback` (`"onDeepLinking"`) β€” note the `udpCallback`/`_udpCallback` naming (likely a "UDL"β†’"UDP" typo) used throughout the iOS plugin for this feature | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None from Dart beyond registering the callback; the deep-link click event itself originates from AppsFlyer's OneLink resolution, delivered into the native SDK via F-039 (iOS) / F-040 (Android) entry points or the SDK's own intent/URL inspection. | +| **Output** | `DeepLinkResult { Status status, Error? error, DeepLink? deepLink }` delivered to the Dart callback passed to `onDeepLinking`. `DeepLink` exposes the raw click-event map plus typed getters (`deepLinkValue`, `matchType`, `clickHttpReferrer`, `mediaSource`, `campaign`, `campaignId`, `afSub1..5`, `isDeferred`). Per `doc/DeepLink.md`, UDL privacy protection means new users' payloads are limited to `deep_link_value`/`deep_link_sub1-10`; other fields (`media_source`, `campaign`, `af_sub1-5`) return `null`. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `onDeepLinking`, `startListeningToUDL`, or the `"onDeepLinking"` branch of `_methodCallHandler` in `lib/src/callbacks.dart`. + +--- + +## Known Limitations +- **Single global callback, no queueing/multi-subscriber support**: `startListeningToUDL` stores the callback in a single module-level `_udlCallback` variable (not the keyed `_callbacksById` map other callbacks use), so registering `onDeepLinking` more than once silently replaces the previous subscriber rather than supporting multiple listeners. +- **iOS naming inconsistency**: the iOS native layer names its UDL-gating flag/method `udpCallback`/`_udpCallback` (`AppsflyerSdkPlugin.h`/`.m`), apparently a typo for "UDL" β€” functionally correct (still keyed off the `"onDeepLinking"` string) but a maintenance trap for anyone searching for `udl` in the iOS code. +- **Mutually exclusive with legacy direct deep linking**: per `doc/DeepLink.md`, migrating to UDL means `onAppOpenAttribution` (F-036) "will not be called" β€” nothing in code enforces or warns if an app registers both `registerOnDeepLinkingCallback` and `registerOnAppOpenAttributionCallback` simultaneously. +- Documentation requires the Dart-side `onDeepLinking` implementation to be registered **before** SDK initialization; nothing in code enforces or warns about this ordering. +- Android caches only the single most recent `DeepLinkResult` across an activity-detach window (`RD-65582` `cachedDeepLinkResult`); rapid multiple deep-link resolutions during a detach period are not individually queued β€” only the latest survives. +- `deepLinkStatus`/`deepLinkError` string parsing (`statusFromString`/`errorFromString`) uses `firstWhere(..., orElse: null)`, which throws if the native string doesn't match a known enum value rather than falling back cleanly (a `Status.PARSE_ERROR` default is only applied when the field itself is null/missing, not when it's an unrecognized string). + +--- + +## Dependencies +```mermaid +flowchart LR + F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking -->|"listener registration gated by UDL flag set in"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + F039["F-039 Β· Native iOS Deep-Link Entry Points"]:::deepLinking -->|"forwards openURL/continueUserActivity/scene events to native SDK, which triggers"| F037 + F040["F-040 Β· Android New-Intent Deep-Link Forwarding"]:::deepLinking -->|"forwards onNewIntent to native SDK, which triggers"| F037 + classDef deepLinking fill:#E64980,color:#fff + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-038-legacy-purchase-validation-notification-callback.md b/internal-docs/features/F-038-legacy-purchase-validation-notification-callback.md new file mode 100644 index 00000000..222f6d9c --- /dev/null +++ b/internal-docs/features/F-038-legacy-purchase-validation-notification-callback.md @@ -0,0 +1,96 @@ +--- +id: F-038 +name: Legacy Purchase-Validation Notification Callback +type: purchaseValidation +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +The legacy V1 purchase-validation APIs (F-023, `validateAndLogInAppAndroidPurchase` / `validateAndLogInAppIosPurchase`) are fire-and-forget: their Dart `Future` resolves as soon as the native call is dispatched, before AppsFlyer's servers have actually validated the receipt against the store. `onPurchaseValidation` is the only way a host app can find out whether that validation ultimately succeeded or failed β€” it registers a Dart callback that native code invokes asynchronously, once, whenever a `"validatePurchase"` event arrives from the native `AppsFlyerInAppPurchaseValidatorListener` (Android) or the `validateAndLogInAppPurchase` success/failure blocks (iOS). Without this callback, apps using the deprecated V1 validation APIs would have no way to observe the validation outcome at all, since V1 does not return it on the call's own `Future` (unlike V2 / F-024). + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app once, typically during setup (before or shortly after calling the V1 validation APIs), to register a listener for the `"validatePurchase"` event. The registered callback then fires asynchronously whenever the native SDK later completes (or fails) a legacy in-app-purchase validation triggered by F-023. + +--- + +## Call Chain +``` +Registration: +AppsflyerSdk.onPurchaseValidation(Function callback) [lib/src/appsflyer_sdk.dart] + β†’ startListening(callback, "validatePurchase") [lib/src/callbacks.dart] + β†’ _callbacksById["validatePurchase"] = callback + β†’ _channel(AF_CALLBACK_CHANNEL /* "callbacks" */).invokeMethod("startListening", "validatePurchase") + β†’ Android: AppsflyerSdkPlugin.callbacksHandler β†’ startListening(arguments, result) [android/.../AppsflyerSdkPlugin.java] + β†’ validatePurchaseCallback = true // gates delivery, see registerValidatorListener() + β†’ iOS: no native handler observed for "startListening" on the callbacks channel (see Known Limitations) + +Delivery (Android): +AppsFlyerInAppPurchaseValidatorListener (registered by registerValidatorListener(), called from + validateAndLogInAppPurchase() in F-023's V1 flow) [android/.../AppsflyerSdkPlugin.java] + β†’ onValidateInApp() / onValidateInAppFailure(String) + β†’ if (validatePurchaseCallback) runOnUIThread(data, AF_VALIDATE_PURCHASE /* "validatePurchase" */, AF_SUCCESS|AF_FAILURE) + β†’ mCallbackChannel.invokeMethod("callListener", jsonArgs) // args = {id, status, data} + β†’ Dart: _methodCallHandler case 'callListener' β†’ case "validatePurchase" [lib/src/callbacks.dart] + β†’ decodes data, builds {"status", "payload"}, invokes _callbacksById["validatePurchase"](fullResponse) + β†’ the app's registered callback runs + +Delivery (iOS): +[AppsFlyerLib shared] validateAndLogInAppPurchase:...success:/failure: (F-023's V1 flow) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ onValidateSuccess:/onValidateFail: + β†’ [_streamHandler sendResponseToFlutter:afValidatePurchase(@"validatePurchase") status:... data:...] [AppsFlyerStreamHandler.m] + β†’ Dart: same _methodCallHandler case 'callListener' β†’ case "validatePurchase" path as Android +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `onPurchaseValidation(Function callback)` β€” thin wrapper calling `startListening(callback, "validatePurchase")` | +| `lib/src/callbacks.dart` | `startListening()` registers the callback in `_callbacksById` and tells native to start listening; `_methodCallHandler` routes incoming `"callListener"` calls whose `id == "validatePurchase"` to the registered callback, wrapping the payload as `{"status", "payload"}` | +| `lib/src/appsflyer_constants.dart` | `AF_VALIDATE_PURCHASE = "validatePurchase"` β€” the shared event id constant | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `startListening(Object, Result)` sets `validatePurchaseCallback = true`; `registerValidatorListener()` builds the `AppsFlyerInAppPurchaseValidatorListener` whose `onValidateInApp()`/`onValidateInAppFailure(String)` gate on that flag and call `runOnUIThread(...)` to push the event to Dart over the `"callbacks"` (`mCallbackChannel`) `MethodChannel` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `AF_VALIDATE_PURCHASE = "validatePurchase"` β€” native-side mirror of the Dart constant | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `onValidateSuccess:`/`onValidateFail:` (fed by F-023's `validateAndLogInAppPurchase:result:`) call `[_streamHandler sendResponseToFlutter:afValidatePurchase ...]` to forward the result | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afValidatePurchase @"validatePurchase"` β€” iOS-side mirror of the same event id | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `sendResponseToFlutter:status:data:` β€” forwards the result to Dart via `invokeMethod("callListener", ...)` on the callback channel (same channel/protocol Android uses) | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `callback` (`Function`) β€” a Dart function accepting one `dynamic` argument, registered once via `onPurchaseValidation`. | +| **Output** | The registered callback is invoked with `{"status": "success"\|"failure", "payload": }` whenever native code reports a `"validatePurchase"` event triggered by a prior F-023 V1 validation call. On Android, `payload` is empty `{}` on success and `{"error": ""}` on failure; on iOS it is the raw validation response dictionary on success and `{"error": ""}` on failure. `onPurchaseValidation` itself returns nothing (`void`, `async` with no awaited work). | + +--- + +## Tests +No dedicated test found. `grep` of `test/` for `onPurchaseValidation`/`validatePurchase` (as a callback registration, not the V1 validate-and-log call already covered by F-023's test) returns no matches β€” the callback-delivery path is untested by the Dart unit suite. The `example/` app also does not appear to call `onPurchaseValidation`. + +--- + +## Known Limitations +- Deprecated-adjacent: this callback only exists to serve the deprecated V1 validation APIs (F-023). V2 (F-024) delivers its result directly on the call's own `Future` and does not need this listener. Apps that have fully migrated to V2 have no reason to register `onPurchaseValidation`. +- On Android, delivery is gated by the `validatePurchaseCallback` boolean, which is only set `true` once `onPurchaseValidation` β†’ `startListening("validatePurchase")` has round-tripped to native; if a V1 validation call resolves before that registration completes, the resulting event is dropped (no buffering/replay), and the app never learns the outcome. +- `_callbacksById` in `callbacks.dart` is a single global map keyed by event id string β€” calling `onPurchaseValidation` more than once silently replaces the previously registered callback rather than fanning out to multiple listeners, and there is no corresponding `cancelListening` call exposed for this specific API (though the underlying `startListening` helper does return a `CancelListening` closure that `onPurchaseValidation` discards). +- iOS delivery is not gated by any equivalent boolean flag: `AppsFlyerStreamHandler.sendResponseToFlutter` always attempts to forward a `"validatePurchase"` event whenever `onValidateSuccess:`/`onValidateFail:` fire, regardless of whether the Dart side ever called `onPurchaseValidation` β€” an asymmetry with Android noted already in F-023's Known Limitations. +- No automated test coverage of the callback-delivery path on either platform. + +--- + +## Dependencies +```mermaid +flowchart LR + F038["F-038 Β· Legacy Purchase-Validation Notification Callback"]:::purchaseValidation + F023["F-023 Β· In-App Purchase Validation V1"]:::purchaseValidation + F023 -->|"delivers async result via"| F038 + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-039-native-ios-deep-link-entry-points.md b/internal-docs/features/F-039-native-ios-deep-link-entry-points.md new file mode 100644 index 00000000..5904492a --- /dev/null +++ b/internal-docs/features/F-039-native-ios-deep-link-entry-points.md @@ -0,0 +1,93 @@ +--- +id: F-039 +name: Native iOS Deep-Link Entry Points (URL scheme / Universal Links / Scenes) +type: deepLinking +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +iOS only tells an app about an incoming deep link through OS delegate callbacks (`application:openURL:...`, `application:continueUserActivity:...`) or, on the UIScene lifecycle (iOS 13+, and required by Flutter 3.41+'s UIScene migration), `scene:...` methods. The AppsFlyer SDK must intercept every one of these entry points β€” including the cold-start case where the OS delivers the launch URL/activity before the Flutter/Dart bridge exists β€” and pass it to the native AppsFlyer SDK so it can resolve OneLink attribution and, ultimately, deliver a `DeepLinkResult` to Dart via F-037. Without this interception layer, deep links opened while the app is fully cold (not yet running) would be silently lost. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Fires whenever iOS launches or resumes the app via a deep link: URI-scheme opens (`openURL`, iOS 9+ and the legacy iOS 8 form), Universal Links (`continueUserActivity`), or β€” when the host app has migrated to Flutter's UIScene-based lifecycle (`FlutterSceneLifeCycleDelegate`, gated by `__has_include()`) β€” the equivalent `scene:openURLContexts:`, `scene:willConnectToSession:options:` (cold start), and `scene:continueUserActivity:` methods. + +--- + +## Call Chain +``` +iOS OS-level deep-link delivery (app already running or resuming): + application:openURL:options: (iOS 9+) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerAttribution shared] handleOpenUrl:url options:options] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m] + application:openURL:sourceApplication:annotation: (iOS 8 and below) + β†’ [[AppsFlyerAttribution shared] handleOpenUrl:url sourceApplication:annotation:] + application:continueUserActivity:restorationHandler: (Universal Links) + β†’ [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:] + +iOS UIScene-based delivery (Flutter 3.41+ UIScene migration, iOS 13+, only compiled when FlutterSceneLifeCycle.h is available): + scene:openURLContexts: β†’ for each context β†’ [[AppsFlyerAttribution shared] handleOpenUrl:context.URL options:opts] + scene:willConnectToSession:options: (cold start via UISceneConnectionOptions) + β†’ for each URLContext β†’ handleOpenUrl:options: + β†’ for each userActivity of type NSUserActivityTypeBrowsingWeb β†’ continueUserActivity:restorationHandler:nil + scene:continueUserActivity: β†’ [[AppsFlyerAttribution shared] continueUserActivity:userActivity restorationHandler:nil] + +AppsFlyerAttribution (buffering singleton, isBridgeReady initially NO) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m] + handleOpenUrl:.../continueUserActivity:... + β†’ if isBridgeReady == YES: forward immediately to [AppsFlyerLib shared] handleOpenUrl:/continueUserActivity: + β†’ else: buffer url/options/sourceApplication/annotation/userActivity/restorationHandler on self + +AppsflyerSdkPlugin initSdkWithCall:result: (Dart called initSdk β†’ method channel β†’ native init) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ ... [AppsFlyerLib shared] init/start ... + β†’ [AppsFlyerAttribution shared].isBridgeReady = YES + β†’ [[NSNotificationCenter defaultCenter] postNotificationName:AF_BRIDGE_SET object:self] + β†’ AppsFlyerAttribution receiveBridgeReadyNotification: (registered as observer in -init) + β†’ flushes any buffered url/options/sourceApplication/annotation/userActivity to [AppsFlyerLib shared] handleOpenUrl:/continueUserActivity: + β†’ native SDK resolves the deep link β†’ triggers F-037 (UDL) delivery to Dart +``` + +--- + +## Files +| File | Role | +|------|------| +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `application:openURL:options:`, `application:openURL:sourceApplication:annotation:`, `application:continueUserActivity:restorationHandler:`, and (behind `FlutterSceneLifeCycle.h` availability) `scene:openURLContexts:`, `scene:willConnectToSession:options:`, `scene:continueUserActivity:` β€” all OS/Scene entry points, each forwarding into `AppsFlyerAttribution`; `initSdkWithCall:result:` sets `isBridgeReady = YES` and posts `AF_BRIDGE_SET` once Dart's `initSdk` call reaches native code | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerAttribution.h` | Declares the `AppsFlyerAttribution` singleton interface: buffering properties (`userActivity`, `restorationHandler`, `url`, `options`, `sourceApplication`, `annotation`), `isBridgeReady` flag, and the `AF_BRIDGE_SET` notification name constant | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m` | Singleton implementation β€” `handleOpenUrl:...`/`continueUserActivity:...` either forward immediately to `AppsFlyerLib` or buffer until `isBridgeReady`; `receiveBridgeReadyNotification:` flushes exactly one buffered event (checked in priority order: sourceApplication+annotation form, then options form, then userActivity form) when notified | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `AppsflyerSdkPlugin` class declaration; conditionally conforms to `FlutterSceneLifeCycleDelegate` when available | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `NSURL`/`NSDictionary` options (URI-scheme opens), `NSUserActivity` (Universal Links), or `UISceneConnectionOptions`/`UIOpenURLContext` sets (UIScene cold start/live events) β€” all supplied by iOS, not by Dart. | +| **Output** | No direct Dart-facing output from this feature; it forwards raw URL/activity data into `[AppsFlyerLib shared]`, which performs OneLink resolution and (if UDL is subscribed, F-037) surfaces a `DeepLinkResult` back through the existing callback channel. | + +--- + +## Tests +No dedicated test found β€” this logic lives entirely in Objective-C native code with no automated coverage found under `test/` (Dart tests only) or any discoverable native (XCTest) test target in `ios/`. + +--- + +## Known Limitations +- **Single-slot buffer, not a queue**: `AppsFlyerAttribution` buffers only one pending deep-link event at a time (a fixed set of instance properties, not a list); if the OS delivers multiple deep-link-shaped events before `isBridgeReady` flips to `YES` (e.g. both a URL and a Universal Link in rapid succession during cold start), only the values from the last call survive β€” earlier ones are silently overwritten. +- **All delegate methods return `NO`**: every intercepted method explicitly returns `NO`/is documented as "Results of this are ORed and NO doesn't affect other delegate interceptors' result" β€” by design, so as not to block other plugins/interceptors from also handling the same URL, but it also means AppsFlyer's interception is invisible to code checking the return value for "was this URL handled." +- **UIScene support is conditionally compiled**: the `scene:...` methods only exist when `__has_include()` is true (Flutter 3.41+); on older Flutter/Flutter engine versions without UIScene support, only the legacy `UIApplicationDelegate` methods run, and per `doc/DeepLink.md` those legacy methods are also documented as unnecessary from plugin v6.4.0+ if the app doesn't override them itself (i.e. AppsFlyer intercepts automatically via method swizzling/plugin registration, not by requiring the host `AppDelegate` to call these directly). +- The `isBridgeReady`/`AF_BRIDGE_SET` handshake depends on Dart actually calling `initSdk`; if the Dart app never initializes the SDK (or does so much later), buffered deep-link data waits indefinitely in `AppsFlyerAttribution`'s single-slot buffer. +- No test coverage exists for any of the buffering/forwarding logic described here. + +--- + +## Dependencies +```mermaid +flowchart LR + F039["F-039 Β· Native iOS Deep-Link Entry Points"]:::deepLinking -->|"forwards resolved URL/activity to native SDK, which triggers"| F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking + classDef deepLinking fill:#E64980,color:#fff +``` diff --git a/internal-docs/features/F-040-android-new-intent-deep-link-forwarding.md b/internal-docs/features/F-040-android-new-intent-deep-link-forwarding.md new file mode 100644 index 00000000..4e782867 --- /dev/null +++ b/internal-docs/features/F-040-android-new-intent-deep-link-forwarding.md @@ -0,0 +1,72 @@ +--- +id: F-040 +name: Android New-Intent Deep-Link Forwarding +type: deepLinking +platform: android +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +The native AppsFlyer Android SDK normally inspects the hosting `Activity`'s intent for deep-link data during `onResume()`. For a warm-started app (already running, brought back to the foreground by a new `VIEW` intent β€” e.g. tapping a OneLink while the app sits in the background), Android delivers that new intent via `onNewIntent`, and the SDK's own `onResume` handling stamps the intent URI with `af_consumed=true` once it has processed it. If the Flutter plugin didn't forward the intent to the SDK itself before that auto-consumption happens, warm-start deep links would either be missed entirely or race against the SDK's own resume-time handling. This feature exists purely to guarantee that warm-start deep links reliably reach AppsFlyer's resolution logic (and, from there, the UDL callback layer, F-037) as reliably as cold-start links do. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Fires whenever Android calls `onNewIntent` on the host `Activity` while the Flutter engine's activity is attached β€” i.e. the app is warm (already running, not being freshly launched) and receives a new `Intent` (typically a `VIEW` intent from a deep link click). + +--- + +## Call Chain +``` +Android delivers a new Intent to the running Activity (app already warm) + β†’ PluginRegistry.NewIntentListener.onNewIntent(Intent intent) [android/.../AppsflyerSdkPlugin.java] + β†’ activity.setIntent(intent) // keep Activity's intent in sync + β†’ if (mApplication != null): AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication) + // forwarded BEFORE the SDK's own onResume auto-handler stamps the URI with af_consumed=true + β†’ native SDK resolves the deep link from the intent + β†’ afDeepLinkListener.onDeepLinking(DeepLinkResult) (if subscribeForDeepLink was called, F-037/UDL path) + β†’ ... delivered to Dart via the callListener/onDeepLinking channel (see F-037) + β†’ onNewIntent returns false (does not claim exclusive handling of the intent) +``` + +--- + +## Files +| File | Role | +|------|------| +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `onNewIntentListener` (`PluginRegistry.NewIntentListener`) β€” calls `activity.setIntent(intent)` then `AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication)`; registered via `binding.addOnNewIntentListener(onNewIntentListener)` in both `onAttachedToActivity` and `onReattachedToActivityForConfigChanges` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | The Android `Intent` delivered to `onNewIntent` (typically a `VIEW` intent carrying a deep-link/OneLink URI), plus the plugin's cached `Activity`/`Application` references. | +| **Output** | No direct Dart-facing output β€” this feature only forwards the intent into `AppsFlyerLib.getInstance().performOnDeepLinking(...)`, which performs deep-link resolution and (if subscribed) delivers a `DeepLinkResult` through the existing UDL callback path (F-037). `onNewIntent` itself returns `false`, signaling it does not consume the intent for any other listener. | + +--- + +## Tests +No dedicated test found β€” no native (JUnit/Robolectric) test target under `android/` covers `onNewIntentListener`, and `test/appsflyer_sdk_test.dart` (Dart-only) does not exercise this native-only code path. + +--- + +## Known Limitations +- **Guarded by `mApplication` nullability, not by activity-attach state generally**: `performOnDeepLinking` is only called `if (mApplication != null)`; `mApplication` is set in `onAttachedToActivity` and never explicitly nulled elsewhere in the visible code except implicitly via activity detach handling, so a new intent arriving in a narrow window before `onAttachedToActivity` runs (or after certain teardown paths) would silently skip forwarding. +- **Race with the native SDK's own `onResume` consumption**: the inline comment in code explicitly documents the reason this forwarding exists β€” "Forward the intent to the SDK before its own onResume auto-handler runs and stamps the URI with `af_consumed=true`. Without this, warm-app VIEW intents get silently consumed and the registered DeepLinkListener never fires for the Dart side." This means the correctness of this feature depends on `onNewIntent` always running before the Activity's `onResume` in the observed lifecycle ordering β€” an assumption inherent to the Android lifecycle but not enforced/asserted in code. +- **No iOS equivalent by nature**: iOS has no concept of `onNewIntent`; the warm-start-equivalent cases on iOS are handled by the always-active `application:openURL:...`/`continueUserActivity:...`/`scene:...` delegate methods (F-039), which do not need a separate "already consumed" race to guard against. +- `onNewIntent` always returns `false`, so it never signals to the Flutter engine's intent-handling chain that it fully handled the intent β€” other registered `NewIntentListener`s (e.g. app-level routing) still run. +- No automated test coverage exists for this listener or its interaction with SDK-internal `onResume` consumption timing. + +--- + +## Dependencies +```mermaid +flowchart LR + F040["F-040 Β· Android New-Intent Deep-Link Forwarding"]:::deepLinking -->|"forwards intent to native SDK before auto-consumption, which triggers"| F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking + classDef deepLinking fill:#E64980,color:#fff +``` diff --git a/internal-docs/features/F-041-current-device-language-override.md b/internal-docs/features/F-041-current-device-language-override.md new file mode 100644 index 00000000..a6ad6617 --- /dev/null +++ b/internal-docs/features/F-041-current-device-language-override.md @@ -0,0 +1,67 @@ +--- +id: F-041 +name: Current Device Language Override +type: platformIntegration +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +AppsFlyer's attribution and in-app-event reporting includes the device's language/locale as a dimension used for reporting and, for some integrated partners, for postback enrichment. Apps that manage their own in-app localization independently of the OS locale (e.g. a language switcher that doesn't change `NSLocale`) need a way to tell AppsFlyer which language the user is actually seeing, rather than relying on the OS-reported value. `setCurrentDeviceLanguage` provides that override. Without it, AppsFlyer would only ever see the OS-level device language, which can diverge from the language actually presented to the user and skew language-based reporting/segmentation for partners that consume it. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever it needs to explicitly declare (or correct) the language reported to AppsFlyer β€” typically during startup configuration or right after an in-app language change. + +--- + +## Call Chain +``` +AppsflyerSdk.setCurrentDeviceLanguage(language) [lib/src/appsflyer_sdk.dart:597] + β†’ _methodChannel.invokeMethod("setCurrentDeviceLanguage", language) + β†’ iOS: AppsflyerSdkPlugin handleMethodCall: case "setCurrentDeviceLanguage" β†’ setCurrentDeviceLanguage:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:155] + β†’ [AppsFlyerLib shared] setCurrentDeviceLanguage: language [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:395] +``` +No `case "setCurrentDeviceLanguage"` exists in `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java`'s method-call switch β€” on Android the call falls through to the default branch and returns `MethodNotImplemented`. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setCurrentDeviceLanguage(String)` β€” platform-agnostic Dart API surface (no `Platform.isIOS` guard) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCurrentDeviceLanguage:result:` native handler, forwards to `AppsFlyerLib.shared` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `language` (String) β€” an IETF/ISO language code (e.g. `"en"`) forwarded as-is; native performs no validation of the string's format. | +| **Output** | `void` β€” fire-and-forget; native always calls `result(nil)`. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart`'s mock method-call handler does not include a `case 'setCurrentDeviceLanguage'`, and no `test(...)` block exercises `instance.setCurrentDeviceLanguage(...)`. + +--- + +## Known Limitations +- **iOS-only**: no Android implementation exists. The Dart API has no `Platform.isIOS` guard, so calling it on Android fails with `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer rather than a documented no-op β€” Android integrators must consult documentation to learn this method has no effect there. +- No dedicated automated test coverage for this method, unlike most other Dart API surface methods in this plugin. +- Native code does not validate the `language` string (e.g. against a locale code list), so malformed input is passed straight through to the underlying SDK. + +--- + +## Dependencies +```mermaid +flowchart LR + F041["F-041 Β· Current Device Language Override"]:::platformIntegration + classDef platformIntegration fill:#495057,color:#fff +``` diff --git a/internal-docs/features/F-042-partner-postback-sharing-filter.md b/internal-docs/features/F-042-partner-postback-sharing-filter.md new file mode 100644 index 00000000..f2330a49 --- /dev/null +++ b/internal-docs/features/F-042-partner-postback-sharing-filter.md @@ -0,0 +1,75 @@ +--- +id: F-042 +name: Partner Postback Sharing Filter +type: platformIntegration +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +AppsFlyer forwards install/event data to integrated partner networks (ad networks, MMPs, analytics vendors) via server-to-server postbacks and API. Advertisers sometimes need to block that forwarding for specific partners or for all of them β€” to comply with GDPR/CCPA data-sharing restrictions, honor a user's opt-out choice, or enforce a business rule about which vendors may receive attribution data. `setSharingFilterForPartners` (and its deprecated predecessors `setSharingFilter`/`setSharingFilterForAllPartners`) is the only API surface for this; without it, the app would have no way to suppress third-party data sharing short of disabling the AppsFlyer SDK entirely via `stop()`, which would also break the advertiser's own attribution. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during startup configuration or in direct response to a user consent/opt-out event, whenever the set of partners allowed to receive S2S postback data needs to change. + +--- + +## Call Chain +``` +AppsflyerSdk.setSharingFilterForPartners(partners) [lib/src/appsflyer_sdk.dart:615] + β†’ _methodChannel.invokeMethod("setSharingFilterForPartners", partners) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setSharingFilterForPartners") β†’ setSharingFilterForPartners(call, result) [android/.../AppsflyerSdkPlugin.java:349,555] + β†’ AppsFlyerLib.getInstance().setSharingFilterForPartners(partners) (only if call.arguments != null) + β†’ iOS: AppsflyerSdkPlugin handleMethodCall: case "setSharingFilterForPartners" β†’ setSharingFilterForPartners:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:157,389] + β†’ [AppsFlyerLib shared] setSharingFilterForPartners: partners + +AppsflyerSdk.setSharingFilter(partners) [DEPRECATED] [lib/src/appsflyer_sdk.dart:603] + β†’ setSharingFilterForPartners(partners) (re-routed in Dart to the method above; native "setSharingFilter" channel handlers still exist but are unreachable from this Dart entry point) + +AppsflyerSdk.setSharingFilterForAllPartners() [DEPRECATED] [lib/src/appsflyer_sdk.dart:609] + β†’ setSharingFilterForPartners(["all"]) (re-routed in Dart to the method above) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setSharingFilterForPartners(List)` (active); `setSharingFilter(List)` and `setSharingFilterForAllPartners()` (`@Deprecated`, both re-route to `setSharingFilterForPartners`) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setSharingFilterForPartners` (active, dispatched via channel), plus dead `setSharingFilter`/`setSharingFilterForAllPartners` channel handlers no longer reachable from the current Dart API | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setSharingFilterForPartners:result:` (active, dispatched via channel), plus dead `setSharingFilter:result:`/`setSharingFilterForAllPartners:` channel handlers no longer reachable from the current Dart API | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `partners` (`List`) β€” partner ID strings (e.g. `'facebook_int'`, `'googleadwords_int'`), or the literal `'all'` to block every partner. Empty list or `null` resets to the default (no filtering). | +| **Output** | `void` β€” fire-and-forget; both native handlers always return success/`nil`. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart`'s mock method-call handler includes `case 'setSharingFilterForAllPartners'` and `case 'setSharingFilter'` (but not `'setSharingFilterForPartners'`, the actual active channel method), and no `test(...)` block exercises any of `instance.setSharingFilter(...)`, `instance.setSharingFilterForAllPartners()`, or `instance.setSharingFilterForPartners(...)`. + +--- + +## Known Limitations +- The Android native handler for the legacy `setSharingFilter` channel method (`android/.../AppsflyerSdkPlugin.java:792`) calls `AppsFlyerLib.getInstance().setSharingFilter()` with **no arguments**, discarding whatever filter list was passed β€” this handler is dead code from the current Dart API (which no longer sends a `"setSharingFilter"` channel call), but it would silently misbehave if ever invoked directly via the channel. +- The Dart mock test harness registers channel-method cases for the deprecated `setSharingFilter`/`setSharingFilterForAllPartners` names rather than the actual active `setSharingFilterForPartners` channel call, so the test scaffolding does not match current production wiring and provides no real coverage for this feature. +- No validation in Dart or native code that partner ID strings are well-formed or recognized; typos silently fail to filter the intended partner. + +--- + +## Dependencies +```mermaid +flowchart LR + F042["F-042 Β· Partner Postback Sharing Filter"]:::platformIntegration + classDef platformIntegration fill:#495057,color:#fff +``` diff --git a/internal-docs/features/F-043-out-of-store-install-source.md b/internal-docs/features/F-043-out-of-store-install-source.md new file mode 100644 index 00000000..ab259544 --- /dev/null +++ b/internal-docs/features/F-043-out-of-store-install-source.md @@ -0,0 +1,72 @@ +--- +id: F-043 +name: Out-of-Store Install Source (Android) +type: platformIntegration +platform: android +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Android apps aren't limited to Google Play distribution β€” they can be side-loaded or distributed via third-party app stores (Facebook, Samsung Galaxy Store, Amazon Appstore, direct APK, etc.). Play Install Referrer, which AppsFlyer normally uses to attribute installs, isn't available for these channels. `setOutOfStore`/`getOutOfStore` let the app declare (and later read back) a custom install-source label so AppsFlyer can still attribute and report on installs that didn't come through Google Play. Without it, installs from alternative distribution channels would show up unattributed or misattributed in AppsFlyer reporting. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +`setOutOfStore` is called by the host app during startup configuration, before or around SDK init, when the app is distributed through a channel other than Google Play. `getOutOfStore` is called on demand whenever the app (or its analytics layer) needs to read back the currently recorded out-of-store source label. + +--- + +## Call Chain +``` +AppsflyerSdk.setOutOfStore(sourceName) [lib/src/appsflyer_sdk.dart:620] + β†’ _methodChannel.invokeMethod("setOutOfStore", sourceName) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setOutOfStore") β†’ setOutOfStore(call, result) [android/.../AppsflyerSdkPlugin.java:355,530] + β†’ AppsFlyerLib.getInstance().setOutOfStore(sourceName) (only if sourceName != null) + +AppsflyerSdk.getOutOfStore() [lib/src/appsflyer_sdk.dart:625] + β†’ _methodChannel.invokeMethod("getOutOfStore") + β†’ Android: AppsflyerSdkPlugin.onMethodCall("getOutOfStore") β†’ getOutOfStore(result) [android/.../AppsflyerSdkPlugin.java:352,526] + β†’ result.success(AppsFlyerLib.getInstance().getOutOfStore(this.mContext)) +``` +Neither `"setOutOfStore"` nor `"getOutOfStore"` has a case in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` β€” on iOS both calls fall through to `result(FlutterMethodNotImplemented)`. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setOutOfStore(String)`, `getOutOfStore()` β€” platform-agnostic Dart API surface (no `Platform.isAndroid` guard) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setOutOfStore`, `getOutOfStore` native handlers | +| `doc/API.md` | Documents both methods as **"Android Only!"** with an explicit `if(Platform.isAndroid)` usage guard recommended in examples | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `setOutOfStore`: `sourceName` (String) β€” a custom install-source label (e.g. `"facebook_int"`); native no-ops if `null`. `getOutOfStore`: no input. | +| **Output** | `setOutOfStore`: `void`, fire-and-forget. `getOutOfStore`: `Future` resolving to the previously-set source label (or the native default if never set). | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setOutOfStore call` (around line 290) asserts the mocked channel receives `'setOutOfStore'` with the string argument; `check getOutOfStore call` (around line 284) asserts the mocked channel receives `'getOutOfStore'`. Tests run in the Dart test harness only and cannot verify the native Android SDK read/write behavior. + +--- + +## Known Limitations +- **Android-only**: no iOS implementation exists (out-of-store distribution/attribution is an Android-specific concern β€” iOS apps are Apple App Store only). The Dart API has no `Platform.isAndroid` guard, so calling either method from iOS results in `MissingPluginException`/`FlutterMethodNotImplemented`; `doc/API.md` documents the "Android Only!" restriction and recommends wrapping calls in `if(Platform.isAndroid)`, but this is not enforced in code. +- `setOutOfStore` silently no-ops if `sourceName` is `null` rather than surfacing an error, which can mask integration mistakes. + +--- + +## Dependencies +```mermaid +flowchart LR + F043["F-043 Β· Out-of-Store Install Source (Android)"]:::platformIntegration + classDef platformIntegration fill:#495057,color:#fff +``` diff --git a/internal-docs/features/F-044-partner-specific-data.md b/internal-docs/features/F-044-partner-specific-data.md new file mode 100644 index 00000000..263ba57c --- /dev/null +++ b/internal-docs/features/F-044-partner-specific-data.md @@ -0,0 +1,69 @@ +--- +id: F-044 +name: Partner-Specific Data +type: platformIntegration +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Some AppsFlyer-integrated partner networks accept custom, partner-defined fields alongside standard attribution data (e.g. a partner's own user ID, campaign metadata, or other identifiers that only that partner's integration understands). `setPartnerData` lets the host app attach an arbitrary key/value payload to a named partner integration so it gets forwarded on postbacks to that specific partner. Without it, the app would have no way to enrich a specific partner's data beyond what the standard AppsFlyer event/attribution schema carries, limiting partner-side matching, deduplication, or reporting capabilities that depend on partner-specific fields. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app whenever it needs to associate custom data with a named partner integration β€” typically during startup configuration or when the relevant partner-specific identifiers become available at runtime. + +--- + +## Call Chain +``` +AppsflyerSdk.setPartnerData(partnerId, partnerData) [lib/src/appsflyer_sdk.dart:630] + β†’ _methodChannel.invokeMethod("setPartnerData", {'partnerId': partnerId, 'partnersData': partnerData}) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setPartnerData") β†’ setPartnerData(call, result) [android/.../AppsflyerSdkPlugin.java:358,546] + β†’ AppsFlyerLib.getInstance().setPartnerData(partnerId, partnerData) (only if partnerData != null) + β†’ iOS: AppsflyerSdkPlugin handleMethodCall: case "setPartnerData" β†’ setPartnerData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m:161,370] + β†’ [AppsFlyerLib shared] setPartnerDataWithPartnerId:partnerId partnerInfo:partnersData +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setPartnerData(String partnerId, Map partnerData)` β€” platform-agnostic Dart API surface | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setPartnerData` native handler | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setPartnerData:result:` native handler | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `partnerId` (String) β€” the AppsFlyer partner integration identifier. `partnerData` (`Map`) β€” arbitrary key/value payload; on Android the handler no-ops if this map is `null`, on iOS an `NSNull` value is normalized to `nil` before being forwarded. | +| **Output** | `void` β€” fire-and-forget; both native handlers always return success/`nil`. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setPartnerData call` (around line 320) asserts the mocked channel receives `'setPartnerData'` with the `{'partnerId': ..., 'partnersData': ...}` argument map. The Dart test harness cannot verify that native code actually forwards the data to `AppsFlyerLib`/`setPartnerDataWithPartnerId:partnerInfo:`, nor that a given partner integration consumes it correctly. + +--- + +## Known Limitations +- No validation that `partnerId` corresponds to an actual integrated/configured partner β€” an unrecognized ID silently has no effect (the data is simply never forwarded by that partner's integration). +- Android silently drops the call if `partnerData` is `null` rather than surfacing an error, which can mask integration mistakes; iOS instead normalizes `NSNull` to `nil` and still invokes the native SDK call. +- No schema/type validation on the contents of `partnerData` β€” arbitrary object values are passed through the channel as-is, so type mismatches would only surface as native-side runtime issues. + +--- + +## Dependencies +```mermaid +flowchart LR + F044["F-044 Β· Partner-Specific Data"]:::platformIntegration + classDef platformIntegration fill:#495057,color:#fff +``` diff --git a/internal-docs/features/F-045-deep-link-url-resolution-allow-list.md b/internal-docs/features/F-045-deep-link-url-resolution-allow-list.md new file mode 100644 index 00000000..54af80a2 --- /dev/null +++ b/internal-docs/features/F-045-deep-link-url-resolution-allow-list.md @@ -0,0 +1,75 @@ +--- +id: F-045 +name: Deep-Link URL Resolution Allow-list +type: deepLinking +platform: both +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Advertisers sometimes wrap an AppsFlyer OneLink inside another Universal Link/App Link domain they control. Opening that wrapper link launches the app correctly, but by default the native SDK has no reason to treat the wrapper's own domain as something it should resolve for deep-link data β€” so the OneLink attribution/deep-link payload underneath never surfaces. `setResolveDeepLinkURLs` lets an app explicitly tell the SDK which additional URL/domains it should attempt to resolve as deep links, so wrapped OneLinks still deliver correct attribution and deep-link data to the app. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called explicitly by the integrating Dart app, typically once at startup (independent of `initSdk`/SDK-start ordering β€” no code enforces call ordering relative to `initSdk`), whenever the app needs to configure which wrapped/custom domains the SDK should resolve as deep links. + +--- + +## Call Chain +``` +AppsflyerSdk.setResolveDeepLinkURLs(List urls) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setResolveDeepLinkURLs", urls) + β†’ Android: onMethodCall(call, result) β†’ case "setResolveDeepLinkURLs" β†’ setResolveDeepLinkURLs(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ urls = (ArrayList) call.arguments β†’ urlsArr = urls.toArray(new String[0]) + β†’ AppsFlyerLib.getInstance().setResolveDeepLinkURLs(urlsArr) + β†’ result.success(null) + β†’ iOS: handleMethodCall: β†’ case "setResolveDeepLinkURLs" β†’ setResolveDeepLinkURLs:call result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ urlsArr = call.arguments (NSArray) β†’ if urlsArr != nil: [[AppsFlyerLib shared] setResolveDeepLinkURLs:urlsArr] + β†’ result(nil) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setResolveDeepLinkURLs(List urls)` β€” thin passthrough invoking the `setResolveDeepLinkURLs` method channel call with the raw URL list | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `onMethodCall` dispatch `case "setResolveDeepLinkURLs"`; `setResolveDeepLinkURLs(MethodCall, Result)` β€” casts arguments to `ArrayList`, converts to `String[]`, calls `AppsFlyerLib.getInstance().setResolveDeepLinkURLs(urlsArr)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | Method-channel dispatch `case @"setResolveDeepLinkURLs"`; `setResolveDeepLinkURLs:result:` β€” passes `call.arguments` (an `NSArray`) directly to `[AppsFlyerLib shared] setResolveDeepLinkURLs:]`, guarded only by a nil check | +| `doc/API.md` | Documents the API (`setResolveDeepLinkURLs`) with the wrapped-OneLink rationale and a usage example; does not restrict it to a single platform | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `List urls` β€” the domains/URLs (e.g. `"clickdomain.com"`) the SDK should attempt to resolve as deep links. | +| **Output** | None (`result.success(null)`/`result(nil)`) β€” this configures internal native SDK state; it does not itself deliver deep-link data. Once configured, subsequently opened URLs matching these domains become eligible for the same deep-link resolution flow that ordinarily feeds F-037 (UDL)/F-035/F-036 (legacy) callbacks. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `setResolveDeepLinkURLs`. + +--- + +## Known Limitations +- **Both platforms implemented, contrary to some Android-only assumptions**: unlike several other Android-specific APIs in this plugin (e.g. `setOutOfStore`, explicitly documented "Android Only!" in `doc/API.md`), `setResolveDeepLinkURLs` has a real native implementation on both Android (`AppsFlyerLib.getInstance().setResolveDeepLinkURLs(String[])`) and iOS (`[AppsFlyerLib shared] setResolveDeepLinkURLs:]`) β€” `doc/API.md` does not flag any platform restriction for this call, and code confirms both platforms are wired. +- **Android**: casts `call.arguments` directly to `ArrayList` with no null/type check before calling `.toArray(...)` β€” passing `null` or a non-list argument from Dart would throw a `NullPointerException`/`ClassCastException` inside the plugin rather than failing gracefully. +- **iOS**: silently no-ops if `urlsArr` is `nil` (still calls `result(nil)` as if successful) β€” a caller passing an unexpected/null value gets no error signal that the call had no effect. +- No ordering guarantee relative to `initSdk`/`startSDK` is enforced or documented; whether URLs must be registered before the SDK starts resolving deep links (to catch a cold-start wrapped link) is not verified by code inspection alone. +- No automated test coverage exists on either the Dart bridge or native implementations for this feature. + +--- + +## Dependencies +```mermaid +flowchart LR + F045["F-045 Β· Deep-Link URL Resolution Allow-list"]:::deepLinking + classDef deepLinking fill:#E64980,color:#fff +``` diff --git a/internal-docs/features/F-046-disable-network-data.md b/internal-docs/features/F-046-disable-network-data.md new file mode 100644 index 00000000..c01c26f8 --- /dev/null +++ b/internal-docs/features/F-046-disable-network-data.md @@ -0,0 +1,67 @@ +--- +id: F-046 +name: Disable Network Data Transfer +type: sdkCore +platform: android +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Carrier/SIM operator name are device-level signals some privacy-conscious apps or regulatory regimes want excluded from what's sent to AppsFlyer, even while the rest of the SDK (attribution, events) stays fully active. `setDisableNetworkData` lets an Android app opt out of collecting the network operator name (carrier) and SIM operator name from the device, without having to disable the SDK (F-017) or anonymize the user (F-013) entirely. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during startup configuration whenever the app needs to opt out of carrier/SIM-operator-name collection for privacy-compliance reasons β€” Android only. + +--- + +## Call Chain +``` +AppsflyerSdk.setDisableNetworkData(disable) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("setDisableNetworkData", disable) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("setDisableNetworkData") β†’ setDisableNetworkData(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().setDisableNetworkData(disable) +``` +No iOS branch exists for `"setDisableNetworkData"` in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` β€” the call falls through to `result(FlutterMethodNotImplemented)`. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `setDisableNetworkData(bool)` β€” no `Platform.isAndroid` guard | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setDisableNetworkData(call, result)`, line 520 | +| `doc/API.md` | Documents the method as **"Android Only!"** and describes it as opting out of "collecting the network operator name (carrier) and sim operator name from the device" | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `disable` (bool) β€” `true` opts out of network/carrier data collection; `false` keeps default collection behavior. | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check setDisableNetworkData call` (line 314) asserts the mocked channel receives `'setDisableNetworkData'`. Test runs only through the Dart mock channel and cannot distinguish Android vs. iOS native behavior. + +--- + +## Known Limitations +- **Android-only**: no corresponding native implementation on iOS. Calling this from a Flutter app running on iOS results in a `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer, since the Dart API has no platform guard. The official docs correctly flag it "Android Only!" with a usage example wrapped in `if (Platform.isAndroid)`, but nothing in the Dart API itself enforces or warns about this. +- The Dart method name (`setDisableNetworkData`) is broader-sounding than its actual, narrower scope (carrier/SIM operator name only, per `doc/API.md`) β€” an integrator relying on the method name alone could over-assume it disables all "network data" transfer generally. + +--- + +## Dependencies +```mermaid +flowchart LR + F046["F-046 Β· Disable Network Data Transfer"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-047-appset-id-collection-optout.md b/internal-docs/features/F-047-appset-id-collection-optout.md new file mode 100644 index 00000000..a42a6a2b --- /dev/null +++ b/internal-docs/features/F-047-appset-id-collection-optout.md @@ -0,0 +1,68 @@ +--- +id: F-047 +name: AppSet ID Collection Opt-out (Android) +type: sdkCore +platform: android +status: active +last_verified: 2026-07-15 +depends_on: [] +--- + +## Business Purpose +Starting with SDK v6.17.0, the Android SDK automatically collects the Google Play "AppSet ID" (a privacy-friendlier alternative to the Advertising ID for app-scoped or developer-scoped device identification). Some apps need to opt out of this automatic collection entirely for privacy-compliance reasons even though it isn't as sensitive as GAID. `disableAppSetId()` is the only way to turn that automatic collection off. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Called by the host app during startup configuration, on Android only, whenever it needs to opt out of automatic AppSet ID collection. + +--- + +## Call Chain +``` +AppsflyerSdk.disableAppSetId() [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("disableAppSetId") + β†’ Android: AppsflyerSdkPlugin.onMethodCall("disableAppSetId") β†’ disableAppSetId(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ AppsFlyerLib.getInstance().disableAppSetId() +``` +No iOS branch exists for `"disableAppSetId"` in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` β€” the call falls through to `result(FlutterMethodNotImplemented)`. This is expected: AppSet ID is a Google Play Services / Android-only concept. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_sdk.dart` | `disableAppSetId()` β€” no-argument, no `Platform.isAndroid` guard | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `disableAppSetId(call, result)`, line 1230 | +| `doc/API.md` | Documents the method as **"Android Only!"**, "Disables AppSet ID collection. Starting with v6.17.0, the SDK can automatically collect the AppSet ID." | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None | +| **Output** | `void` β€” fire-and-forget; no confirmation returned to Dart. | + +--- + +## Tests +`test/appsflyer_sdk_test.dart` β€” `check disableAppSetId call` (line 362) asserts the mocked channel receives `'disableAppSetId'`. Test runs only through the Dart mock channel and cannot distinguish Android vs. iOS/no-op native behavior. + +--- + +## Known Limitations +- **Android-only** (by design β€” AppSet ID is a Google Play Services concept with no iOS equivalent). Calling this from a Flutter app running on iOS results in a `MissingPluginException`/`FlutterMethodNotImplemented`, since the Dart API has no platform guard. Official docs correctly flag it "Android Only!" with a usage example wrapped in `if (Platform.isAndroid)`. +- There is no way to re-enable AppSet ID collection once disabled within the same process β€” the call is one-directional (opt-out only), matching the native SDK's own API shape. +- No getter to confirm whether AppSet ID collection is currently disabled. + +--- + +## Dependencies +```mermaid +flowchart LR + F047["F-047 Β· AppSet ID Collection Opt-out (Android)"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-048-plugin-metadata-reporting.md b/internal-docs/features/F-048-plugin-metadata-reporting.md new file mode 100644 index 00000000..65cfcf93 --- /dev/null +++ b/internal-docs/features/F-048-plugin-metadata-reporting.md @@ -0,0 +1,77 @@ +--- +id: F-048 +name: Plugin Metadata Reporting to Native SDK +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-001"] +--- + +## Business Purpose +AppsFlyer maintains multiple wrapper SDKs on top of its native Android/iOS SDKs (Flutter, React Native, Cordova, Unity, etc.). `setPluginInfo`/`setPluginInfoWith:` tells the native SDK "this install is running through the Flutter plugin, version X" so AppsFlyer's backend, support tooling, and internal dashboards can attribute traffic/bugs to the correct wrapper and version rather than treating every install as a bare native integration. This has no effect on attribution logic or app behavior β€” it is purely an internal identification tag with no host-app-facing API. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Runs automatically and unconditionally on every SDK initialization (`initSdk`/`initSdkWithCall:`), on both platforms. There is no Dart API, option, or flag that controls or disables it β€” it always fires as a side effect of `AppsflyerSdk.initSdk()`. + +--- + +## Call Chain +``` +AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("initSdk") β†’ initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ new PluginInfo(Plugin.FLUTTER, AppsFlyerConstants.PLUGIN_VERSION) (line 1095) + β†’ AppsFlyerLib.getInstance().setPluginInfo(pluginInfo) (line 1096) + β†’ AppsFlyerLib.getInstance().init(afDevKey, gcdListener, mContext) (called right after) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") β†’ initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ [[AppsFlyerLib shared] setPluginInfoWith:AFSDKPluginFlutter + pluginVersion:kAppsFlyerPluginVersion + additionalParams:nil] (line 857) + β†’ [[AppsFlyerLib shared] start] (unless manualStart) +``` + +--- + +## Files +| File | Role | +|------|------| +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` β€” builds `PluginInfo(Plugin.FLUTTER, AppsFlyerConstants.PLUGIN_VERSION)` and calls `setPluginInfo` (lines 1095–1096), immediately before `instance.init(...)` | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `PLUGIN_VERSION = "6.18.0"` β€” the version string reported to the native SDK | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β€” calls `setPluginInfoWith:AFSDKPluginFlutter pluginVersion:kAppsFlyerPluginVersion additionalParams:nil` (line 857) | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define kAppsFlyerPluginVersion @"6.18.0"` β€” the version string reported on iOS | + +`Plugin`, `PluginInfo` (Android, package `com.appsflyer.internal.platform_extension`) and `AFSDKPluginFlutter` (iOS, an enum/constant defined inside the native `AppsFlyerLib` framework) are external types supplied by the native AppsFlyer SDK dependency, not defined in this repo. + +--- + +## Input / Output +| | | +|--|--| +| **Input** | None from the host app β€” no Dart parameter exists. The reported values (`Plugin.FLUTTER` / `AFSDKPluginFlutter`, and the hardcoded native `PLUGIN_VERSION` constant) are fixed by the plugin's native-layer source code. | +| **Output** | `void` β€” fire-and-forget call into the native SDK; nothing is returned to Dart. The metadata is transmitted internally by the native SDK to AppsFlyer's backend as part of its own request payloads. | + +--- + +## Tests +No dedicated test found. The call is not exposed as a distinct Dart method (it is embedded inside native `initSdk`/`initSdkWithCall:` handlers), so it cannot be observed or asserted from `test/appsflyer_sdk_test.dart`'s mocked `MethodChannel`, which only sees the single `"initSdk"` method invocation and its arguments map β€” `setPluginInfo`/`setPluginInfoWith:` happen entirely on the native side afterward. + +--- + +## Known Limitations +- The reported plugin version is duplicated independently in three places and has drifted: Dart's `AppsflyerConstants.PLUGIN_VERSION` (`lib/src/appsflyer_constants.dart`) is `"6.17.9"`, while Android's `AppsFlyerConstants.PLUGIN_VERSION` and iOS's `kAppsFlyerPluginVersion` are both `"6.18.0"` (matching `pubspec.yaml`). Since this feature only ever reads the **native**-side constants, the value AppsFlyer's backend actually receives is `6.18.0`, not the value `AppsflyerSdk.getVersionNumber()` (F-003) returns to the host app β€” there is no single source of truth tying the three together. +- No public Dart API exists to inspect, override, or disable the reported plugin metadata; it is entirely internal and always fires on init with no error handling or confirmation callback. +- On Android, `setPluginInfo` is called before `instance.init(...)`; if `init` throws or the SDK is torn down and re-initialized, there is no guard against calling `setPluginInfo` more than once with a stale instance. + +--- + +## Dependencies +```mermaid +flowchart LR + F048["F-048 Β· Plugin Metadata Reporting to Native SDK"]:::sdkCore -->|"runs inside the same native call as"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-049-purchase-connector-configuration-lifecycle.md b/internal-docs/features/F-049-purchase-connector-configuration-lifecycle.md new file mode 100644 index 00000000..83d0c00f --- /dev/null +++ b/internal-docs/features/F-049-purchase-connector-configuration-lifecycle.md @@ -0,0 +1,94 @@ +--- +id: F-049 +name: Purchase Connector: Configuration & Lifecycle +type: purchaseValidation +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-054", "F-051", "F-052"] +--- + +## Business Purpose +Apps that sell subscriptions or in-app purchases need AppsFlyer to automatically detect and validate those transactions server-side (ROI360 revenue measurement) instead of the app manually calling `logEvent` for every purchase. This feature is the on/off switch and settings panel for that automation: it creates the native `PurchaseConnector`/`PurchaseClient` singleton with the app's chosen options (log subscriptions, log in-apps, sandbox mode, StoreKit version on iOS) and then starts or stops the listener that watches the Play Billing Library / StoreKit transaction stream. Without it, no automatic purchase/subscription revenue would ever reach AppsFlyer β€” the app would be limited to manual event logging, losing ROI360 in-app revenue measurement entirely. It is also the foundational dependency for every other Purchase Connector capability (validation-result listeners, iOS combined callback, StoreKit version selection) β€” none of them can do anything until this configuration/lifecycle step has run. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +- **Configure**: runs once, the first time the app calls `PurchaseConnector(config: PurchaseConnectorConfiguration(...))` in Dart (factory constructor of `_PurchaseConnectorImpl`). +- **Start/Stop observing**: runs whenever the app explicitly calls `afPurchaseClient.startObservingTransactions()` / `.stopObservingTransactions()` β€” typically right after `AppsflyerSdk.startSDK()` (per `doc/PurchaseConnector.md`), and `stopObservingTransactions()` right before the core SDK's `stop()` if the user opts out of tracking. + +--- + +## Call Chain +``` +PurchaseConnector(config: ...) β†’ _PurchaseConnectorImpl factory [lib/src/purchase_connector/purchase_connector.dart] + β†’ _PurchaseConnectorImpl._internal() builds configMap {logSubscriptionPurchase, logInAppPurchase, sandbox, storeKitVersion} + β†’ _methodChannel.invokeMethod("configure", configMap) (channel "af-purchase-connector") + β†’ Android (include-connector): AppsFlyerPurchaseConnector.onMethodCall("configure") β†’ configure(call, result) [android/src/main/include-connector/.../AppsFlyerPurchaseConnector.kt] + β†’ new ConnectorWrapper(ctx, logSubs, logInApps, sandbox, arsListener, viapListener) [android/src/main/include-connector/.../ConnectorWrapper.kt] + β†’ PurchaseClient.Builder(context, Store.GOOGLE).setSandbox(...).logSubscriptions(...).autoLogInApps(...).build() + β†’ iOS: PurchaseConnectorPlugin.methodCallHandler("configure") β†’ configure(call:result:) [ios/PurchaseConnector/PurchaseConnectorPlugin.swift] + β†’ connector = PurchaseConnector.shared(); connector.autoLogPurchaseRevenue = options; connector.isSandbox = sandbox; connector.setStoreKitVersion(.SK1/.SK2) + +afPurchaseClient.startObservingTransactions() + β†’ _methodChannel.invokeMethod("startObservingTransactions") + β†’ Android: connectorOperation β†’ connectorWrapper.startObservingTransactions() β†’ PurchaseClient.startObservingTransactions() + β†’ iOS: connectorOperation β†’ connector.startObservingTransactions() (re-applies logOptions first, per StoreKit docs) + +afPurchaseClient.stopObservingTransactions() + β†’ _methodChannel.invokeMethod("stopObservingTransactions") + β†’ Android: connectorWrapper.stopObservingTransactions() + β†’ iOS: connector.stopObservingTransactions() +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/purchase_connector/purchase_connector.dart` | `PurchaseConnector` factory + `_PurchaseConnectorImpl`: builds config map, singleton guard, `startObservingTransactions()`/`stopObservingTransactions()` | +| `lib/src/purchase_connector/purchase_connector_configuration.dart` | `PurchaseConnectorConfiguration` β€” `logSubscriptions`, `logInApps`, `sandbox`, `storeKitVersion` | +| `lib/src/purchase_connector/store_kit_version.dart` | `StoreKitVersion` enum (SK1=0, SK2=1) with `value`/`fromValue` int mapping sent over the channel | +| `lib/src/appsflyer_constants.dart` | Channel name (`af-purchase-connector`) and argument key string constants | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | Android native method-channel handler: `configure`, `startObservingTransactions`, `stopObservingTransactions` | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/ConnectorWrapper.kt` | Wraps `PurchaseClient.Builder` (Play Billing) and the two validation listeners | +| `ios/PurchaseConnector/PurchaseConnectorPlugin.swift` | iOS native method-channel handler: `configure`, `startObservingTransactions`, `stopObservingTransactions`; owns the `PurchaseConnector.shared()` singleton | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `configure`: `logSubscriptionPurchase` (bool), `logInAppPurchase` (bool), `sandbox` (bool), `storeKitVersion` (int, iOS only: 0=SK1, 1=SK2). `startObservingTransactions`/`stopObservingTransactions`: no arguments. | +| **Output** | `configure` returns `void`/`nil` on success; native returns a `FlutterError`/`MethodChannel.Result.error` with code `"401"` if already configured. `startObservingTransactions`/`stopObservingTransactions` return `void`/`nil` on success, or error code `"404"` ("Connector not configured, did you called `configure` first?") if called before `configure`. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` contains no references to `PurchaseConnector`, `configure`, `startObservingTransactions`, or `stopObservingTransactions` on either platform. + +--- + +## Known Limitations +- Re-configuration is silently ignored, not rejected: on the Dart side, calling the `PurchaseConnector(config: ...)` factory again after the singleton already exists just logs `AppsflyerConstants.RE_CONFIGURE_ERROR_MSG` via `debugPrint` and returns the existing instance β€” the new config is dropped with no exception, which can mask an app bug where a second call believed it changed sandbox/logging settings. On the native side (Android/iOS) a second raw `configure` MethodChannel call does return an explicit `"401"` error, so Dart and native disagree on how loudly a re-configure attempt is reported. +- `startObservingTransactions`/`stopObservingTransactions` on the Dart side are fire-and-forget (`_methodChannel.invokeMethod(...)` result is not awaited or checked) β€” if native returns the `"404"` "not configured" error, the Dart caller never sees it. +- iOS StoreKit 2 selection silently falls back to StoreKit 1 on iOS < 15.0 (`PurchaseConnectorPlugin.configure`), with only a `print` statement β€” an app targeting iOS 15+ that assumed SK2 semantics on an older OS gets SK1 behavior with no error surfaced to Dart. +- `doc/PurchaseConnector.md` documents "call `startObservingTransactions` right after `AppsflyerSdk.startSDK()`" and "call `stopObservingTransactions` right before the core SDK's `stop()`" as best practice, but nothing in code enforces or checks core-SDK start state β€” the ordering is a documentation convention only, not a code dependency (see F-003/sdkCore init β€” no genuine code coupling found). +- Entire feature is a no-op unless the app opted in at build time (see F-054); nothing in the Dart-only view (this file's code) tells the caller whether the native side is even present. + +--- + +## Dependencies +```mermaid +flowchart LR + F049["F-049 Β· Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation + F054["F-054 Β· Purchase Connector: Build-Time Opt-in"]:::purchaseValidation + F051["F-051 Β· Purchase Connector: Android Validation Result Listeners"]:::purchaseValidation + F052["F-052 Β· Purchase Connector: iOS Combined Validation Callback"]:::purchaseValidation + F049 -->|"only compiles/registers when enabled by"| F054 + F049 -->|"Android: requires listener object from"| F051 + F049 -->|"iOS: requires delegate from"| F052 + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-050-purchase-connector-storekit-version-selection.md b/internal-docs/features/F-050-purchase-connector-storekit-version-selection.md new file mode 100644 index 00000000..0c8596d9 --- /dev/null +++ b/internal-docs/features/F-050-purchase-connector-storekit-version-selection.md @@ -0,0 +1,75 @@ +--- +id: F-050 +name: "Purchase Connector: StoreKit Version Selection (iOS)" +type: purchaseValidation +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: ["F-049"] +--- + +## Business Purpose +StoreKit 2 (iOS 15+) gives Apple's transaction-observation APIs better reliability and richer transaction data than the legacy StoreKit 1 API, but StoreKit 1 remains the only option on pre-iOS-15 devices. `storeKitVersion` on `PurchaseConnectorConfiguration` lets the app pick which StoreKit generation the native `PurchaseConnector` iOS SDK uses to auto-detect and validate purchases (feeding F-049's `startObservingTransactions`). Without this switch, the app would be stuck on whatever single default the native SDK picks, unable to opt into StoreKit 2's improvements on supported OS versions or to deliberately stay on StoreKit 1 for compatibility/testing reasons. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Applied exactly once, at the same moment F-049's `configure` runs β€” the first time the app constructs `PurchaseConnector(config: PurchaseConnectorConfiguration(storeKitVersion: ...))`. There is no independent runtime trigger and no way to change it later: because `_PurchaseConnectorImpl` is a Dart-side singleton that ignores config on subsequent constructions, and the native iOS `configure` handler refuses to run twice (`"401" "Connector already configured"`), the StoreKit version is fixed for the lifetime of the app process. + +--- + +## Call Chain +``` +PurchaseConnectorConfiguration(storeKitVersion: StoreKitVersion.SK2) [lib/src/purchase_connector/purchase_connector_configuration.dart] + β†’ StoreKitVersion.value (SK1 β†’ 0, SK2 β†’ 1) [lib/src/purchase_connector/store_kit_version.dart] + β†’ _PurchaseConnectorImpl._internal() builds configMap[STORE_KIT_VERSION_KEY] = value [lib/src/purchase_connector/purchase_connector.dart] + β†’ _methodChannel.invokeMethod("configure", configMap) (channel "af-purchase-connector", shared with F-049) + β†’ iOS: PurchaseConnectorPlugin.methodCallHandler("configure") β†’ configure(call:result:) [ios/PurchaseConnector/PurchaseConnectorPlugin.swift] + β†’ reads Int arg "storeKitVersion" (default 0) + β†’ if 1 and #available(iOS 15.0, *): connector!.setStoreKitVersion(.SK2) + else: connector!.setStoreKitVersion(.SK1) (native PurchaseConnector iOS SDK API) +``` +Android's native `configure` handler (`ConnectorWrapper`/`AppsFlyerPurchaseConnector.kt`) never reads the `storeKitVersion` key at all β€” the field travels through Android's channel call but has no effect there. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/purchase_connector/store_kit_version.dart` | `StoreKitVersion` enum (`SK1`, `SK2`) with `value`/`fromValue` int mapping | +| `lib/src/purchase_connector/purchase_connector_configuration.dart` | `storeKitVersion` field, defaults to `StoreKitVersion.SK1` | +| `lib/src/purchase_connector/purchase_connector.dart` | Packs `config.storeKitVersion.value` into the shared `configure` map | +| `lib/src/appsflyer_constants.dart` | `STORE_KIT_VERSION_KEY = "storeKitVersion"` | +| `ios/PurchaseConnector/PurchaseConnectorPlugin.swift` | `configure(call:result:)` β€” reads the int, iOS-15 availability check, calls native `connector.setStoreKitVersion(.SK1/.SK2)` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `storeKitVersion` (int, 0 = SK1, 1 = SK2) β€” part of the same `configure` payload documented in F-049; not a standalone method call. | +| **Output** | `void` β€” no confirmation is returned to Dart of which StoreKit version was actually applied. If SK2 is requested on iOS < 15.0, the plugin silently falls back to SK1 and only logs a `print` statement natively; Dart has no way to detect this fallback happened. | + +--- + +## Tests +No dedicated test found. Grepping `test/` for `StoreKitVersion`/`storeKitVersion` returns no matches. + +--- + +## Known Limitations +- Not a separate API β€” it is a field folded into F-049's single `configure` call, so it inherits all of F-049's configure-time constraints (cannot be changed after first instantiation; a second `PurchaseConnector(config:...)` call silently drops the new config on Dart side, or gets a native `"401"` error if called again directly). +- No feedback path to Dart about which version was actually applied β€” the iOS-15 fallback from SK2 to SK1 is invisible to the app. +- The Dart-level enum/field is shared cross-platform code but is entirely inert on Android β€” `ConnectorWrapper.kt`'s `configure()` never reads `STORE_KIT_VERSION_KEY`. +- The whole feature is a no-op unless the app opted into the Purchase Connector at build time (`$AppsFlyerPurchaseConnector = true` in the Podfile, which conditionally pulls in the `PurchaseConnector` podspec subspec with `ENABLE_PURCHASE_CONNECTOR=1`) β€” otherwise `ios/PurchaseConnector/PurchaseConnectorPlugin.swift` isn't even compiled into the app (see F-054). + +--- + +## Dependencies +```mermaid +flowchart LR + F050["F-050 Β· Purchase Connector: StoreKit Version Selection (iOS)"]:::purchaseValidation -->|"requires configuration from"| F049["F-049 Β· Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-051-purchase-connector-android-validation-result-listeners.md b/internal-docs/features/F-051-purchase-connector-android-validation-result-listeners.md new file mode 100644 index 00000000..eb054539 --- /dev/null +++ b/internal-docs/features/F-051-purchase-connector-android-validation-result-listeners.md @@ -0,0 +1,89 @@ +--- +id: F-051 +name: "Purchase Connector: Android Validation Result Listeners" +type: purchaseValidation +platform: android +status: active +last_verified: 2026-07-15 +depends_on: ["F-049"] +--- + +## Business Purpose +On Android, F-049's `startObservingTransactions()` makes the native purchase-connector library automatically send every subscription (ARS) and in-app purchase (VIAP) transaction to AppsFlyer's server for validation, but that validation happens out-of-band from the app's own code. `setSubscriptionValidationResultListener` and `setInAppValidationResultListener` are how the app finds out the outcome of that server round trip β€” a typed success/failure result per purchase β€” so it can, for example, gate premium content on a confirmed-valid purchase or log a diagnostic when validation fails. Without these listeners the app would have automatic revenue attribution but zero visibility into whether any individual purchase was actually validated. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +- **Registration**: whenever the app calls `afPurchaseClient.setSubscriptionValidationResultListener(onResponse, onFailure)` / `setInAppValidationResultListener(onResponse, onFailure)`. This only stores the callbacks in Dart instance fields β€” no native call is made. +- **Delivery**: whenever Google Play Billing reports a subscription or in-app purchase transaction and the Android `purchase-connector` library finishes validating it against AppsFlyer's server (success, i.e. any 200/OK response including an invalid-purchase verdict, or failure, i.e. a network exception or non-200 response). + +--- + +## Call Chain +Registration (Dart-only, no channel call): +``` +afPurchaseClient.setSubscriptionValidationResultListener(onResponse, onFailure) [lib/src/purchase_connector/purchase_connector.dart] + β†’ stores _arsOnResponse / _arsOnFailure +afPurchaseClient.setInAppValidationResultListener(onResponse, onFailure) + β†’ stores _viapOnResponse / _viapOnFailure +``` + +Reverse direction β€” native listener fires β†’ data serialized β†’ delivered to Dart: +``` +Google Play Billing purchase event β†’ PurchaseClient (Android purchase-connector lib) validates with AppsFlyer server + β†’ PurchaseClient.SubscriptionPurchaseValidationResultListener.onResponse/onFailure (anonymous impl set in PurchaseClient.Builder) [android/.../include-connector/ConnectorWrapper.kt] + β†’ result.mapValues { it.toJsonMap() } β†’ arsListener.onResponse(...) / arsListener.onFailure(result, error) + β†’ arsListener (MappedValidationResultListener) β†’ methodChannel.invokeMethodOnUI("SubscriptionPurchaseValidationResultListener:onResponse" / ":onFailure", data) [android/.../include-connector/AppsFlyerPurchaseConnector.kt] + β†’ Dart _methodCallHandler(call) [lib/src/purchase_connector/purchase_connector.dart] + β†’ _handleSubscriptionPurchaseValidationResultListenerOnResponse/OnFailure + β†’ SubscriptionValidationResultMap.fromJson(...) / JVMThrowable.fromJson(...) β†’ _arsOnResponse!(...) / _arsOnFailure!(...) +``` +The in-app path is identical, via `viapListener` β†’ `InAppValidationResultListener:onResponse`/`:onFailure` β†’ `_handleInAppValidationResultListenerOnResponse`/`OnFailure` β†’ `_viapOnResponse`/`_viapOnFailure`. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/purchase_connector/purchase_connector.dart` | `setSubscriptionValidationResultListener`/`setInAppValidationResultListener` setters; `_methodCallHandler` routing; `_handle*` parse-and-dispatch methods | +| `lib/src/purchase_connector/connector_callbacks.dart` | `OnResponse` / `OnFailure` typedefs | +| `lib/src/purchase_connector/models/subscription_validation_result.dart` | `SubscriptionValidationResult(success, subscriptionPurchase, failureData)` + `SubscriptionValidationResultMap` wrapper | +| `lib/src/purchase_connector/models/in_app_purchase_validation_result.dart` | `InAppPurchaseValidationResult(success, productPurchase, failureData)` + `InAppPurchaseValidationResultMap` wrapper | +| `lib/src/purchase_connector/models/validation_failure_data.dart` | `ValidationFailureData(status, description)` | +| `lib/src/purchase_connector/models/jvm_throwable.dart` | `JVMThrowable(type, message, stacktrace, cause)` β€” models a JVM `Throwable`; Android/JVM-specific concept | +| `lib/src/appsflyer_constants.dart` | Method-name string constants for the four callback events | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | `arsListener`/`viapListener` (`MappedValidationResultListener`), `invokeMethodOnUI` bridging to Dart | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/ConnectorWrapper.kt` | Wires `PurchaseClient.Builder`'s validation listeners to `arsListener`/`viapListener`; `toJsonMap()`/`Throwable.toMap()` converters | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `onResponse: Function(Map? result)` where `T` is `SubscriptionValidationResult` or `InAppPurchaseValidationResult`; `onFailure: Function(String result, JVMThrowable? error)`. Native payloads arrive as a JSON string (`JSONObject(args).toString()` on Kotlin, `jsonDecode(call.arguments)` on Dart). | +| **Output** | Invokes the app-supplied `onResponse`/`onFailure` with parsed Dart model instances. If no listener was registered when the event arrives, `_handleValidationResultListenerOnResponse`/`OnFailure` silently drop it (no buffering/replay). | + +--- + +## Tests +No dedicated test found. Grepping `test/` for `PurchaseConnector`, `SubscriptionValidationResult`, `InAppPurchaseValidationResult`, or `JVMThrowable` returns no matches. + +--- + +## Known Limitations +- **Verified method-name mismatch that breaks delivery today**: `lib/src/appsflyer_constants.dart` defines the response/failure method names with a `#` separator (`"SubscriptionPurchaseValidationResultListener#onResponse"`, `"InAppValidationResultListener#onFailure"`, etc.), but `AppsFlyerPurchaseConnector.kt` actually invokes the method channel with a `:` separator (`"SubscriptionPurchaseValidationResultListener:onResponse"`, `"InAppValidationResultListener:onFailure"`). Since `MethodCall.method` on the Dart side is whatever string native sent, `_methodCallHandler`'s `switch` never matches these cases and falls through to `default: throw ArgumentError("Method not found: ...")`. As currently written, these two listeners cannot receive any event from Android β€” this is a functional break, not a hypothetical risk. +- No listener exists on iOS for these two method names β€” they are only ever invoked from Android's `AppsFlyerPurchaseConnector.kt`. Calling either setter on iOS compiles and stores the handler but it will never fire (see F-052 for the iOS equivalent). +- `JVMThrowable` models a JVM stack trace as a single joined string plus a recursively nested `cause` β€” a concept meaningless outside this Android validation-result path. +- Android's Purchase Connector source (`AppsFlyerPurchaseConnector.kt`, `ConnectorWrapper.kt`) only compiles when `appsflyer.enable_purchase_connector=true` in `gradle.properties` (Gradle selects the `include-connector` vs `exlude-connector` source set). If not opted in, the `exlude-connector` stub `AppsFlyerPurchaseConnector` object has no method-channel handler at all, and these listeners never receive anything even though the Dart setters succeed silently (see F-054). + +--- + +## Dependencies +```mermaid +flowchart LR + F051["F-051 Β· Purchase Connector: Android Validation Result Listeners"]:::purchaseValidation -->|"requires configuration from"| F049["F-049 Β· Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation + F053["F-053 Β· Purchase Connector: Google Play Data Models"]:::purchaseValidation -->|"payload shape for"| F051 + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-052-purchase-connector-ios-combined-validation-callback.md b/internal-docs/features/F-052-purchase-connector-ios-combined-validation-callback.md new file mode 100644 index 00000000..96a54d17 --- /dev/null +++ b/internal-docs/features/F-052-purchase-connector-ios-combined-validation-callback.md @@ -0,0 +1,87 @@ +--- +id: F-052 +name: "Purchase Connector: iOS Combined Validation Callback" +type: purchaseValidation +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: ["F-049"] +--- + +## Business Purpose +On iOS, once F-049's `startObservingTransactions()` is active, the native `PurchaseConnector` SDK automatically sends every StoreKit transaction (subscription or in-app purchase) to AppsFlyer's server for revenue validation. `setDidReceivePurchaseRevenueValidationInfo` is the app's only window into that outcome on iOS β€” a single combined callback carrying the raw validation info and/or an error. Without it, revenue would still be attributed automatically, but the app would have no way to confirm a given purchase was validated (e.g. to gate premium content unlock, or to log/alert on validation failures). + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +- **Registration**: whenever the app calls `afPurchaseClient.setDidReceivePurchaseRevenueValidationInfo(callback)` β€” stores the callback in a Dart instance field, no native call is made. +- **Delivery**: whenever the native iOS `PurchaseConnector`'s StoreKit transaction observer (active once F-049's `startObservingTransactions` has run and the delegate was assigned during F-049's `configure`) finishes validating a transaction's revenue with AppsFlyer's server, success or failure. + +--- + +## Call Chain +Registration (Dart-only, no channel call): +``` +afPurchaseClient.setDidReceivePurchaseRevenueValidationInfo(callback) [lib/src/purchase_connector/purchase_connector.dart] + β†’ stores _didReceivePurchaseRevenueValidationInfo +``` + +Reverse direction β€” native delegate fires β†’ data serialized β†’ delivered to Dart: +``` +StoreKit transaction observed (via F-049 startObservingTransactions) + β†’ native PurchaseConnector iOS SDK validates revenue with AppsFlyer server + β†’ PurchaseConnectorPlugin.didReceivePurchaseRevenueValidationInfo(_:error:) [ios/PurchaseConnector/PurchaseConnectorPlugin.swift] + (delegate conformance to `PurchaseRevenueDelegate`; `connector.purchaseRevenueDelegate = self` assigned during F-049's `configure`) + β†’ resMap = ["validationInfo": validationInfo, "error": error?.asDictionary] + β†’ DispatchQueue.main.async { methodChannel?.invokeMethod("didReceivePurchaseRevenueValidationInfo", arguments: resMap.toJSONString()) } + β†’ Dart _methodCallHandler(call) [lib/src/purchase_connector/purchase_connector.dart] + β†’ case AppsflyerConstants.DID_RECEIVE_PURCHASE_REVENUE_VALIDATION_INFO + β†’ _handleDidReceivePurchaseRevenueValidationInfo(callMap) + β†’ validationInfo = callMap["validationInfo"] as Map? (untyped, passed through as-is) + error = callMap["error"] != null ? IosError.fromJson(...) : null + β†’ _didReceivePurchaseRevenueValidationInfo!(validationInfo, error) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/purchase_connector/purchase_connector.dart` | `setDidReceivePurchaseRevenueValidationInfo` setter; `_handleDidReceivePurchaseRevenueValidationInfo` parse-and-dispatch | +| `lib/src/purchase_connector/connector_callbacks.dart` | `DidReceivePurchaseRevenueValidationInfo` typedef: `Function(Map? validationInfo, IosError? error)` | +| `lib/src/purchase_connector/models/ios_error.dart` | `IosError(localizedDescription, domain, code)` | +| `lib/src/appsflyer_constants.dart` | `DID_RECEIVE_PURCHASE_REVENUE_VALIDATION_INFO`, `VALIDATION_INFO`, `ERROR` key constants | +| `ios/PurchaseConnector/PurchaseConnectorPlugin.swift` | `PurchaseRevenueDelegate` conformance, `didReceivePurchaseRevenueValidationInfo`, `Error.asDictionary` extension, `Dictionary.toJSONString()` extension | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `callback: DidReceivePurchaseRevenueValidationInfo = Function(Map? validationInfo, IosError? error)`. | +| **Output** | `validationInfo` is delivered as a raw, untyped `Map?` β€” there is no parsed Dart model for its shape (unlike Android's typed `SubscriptionValidationResult`/`InAppPurchaseValidationResult` in F-051). `error` is a typed `IosError` (`localizedDescription`, `domain`, `code`) populated from `NSError` when available, else `localizedDescription` only with `domain`/`code` absent from the map (Swift's `Error.asDictionary` only adds those keys for `NSError`). | + +--- + +## Tests +No dedicated test found. Grepping `test/` for `setDidReceivePurchaseRevenueValidationInfo`, `didReceivePurchaseRevenueValidationInfo`, or `IosError` returns no matches. + +--- + +## Known Limitations +- `validationInfo` is untyped (`Map?`) β€” callers must know the native `PurchaseRevenueDelegate` payload shape themselves; there is no equivalent of Android's `SubscriptionPurchase`/`ProductPurchase` models on the iOS side. +- One combined callback serves both subscriptions and in-app purchases with no discriminator field surfaced by the Dart API β€” the app must inspect `validationInfo`'s contents itself to tell them apart. Contrasts with Android's two separate typed listeners (F-051). +- `IosError` only captures `domain`/`code` when the underlying error is an `NSError`; a plain Swift `Error` yields `localizedDescription` only. +- No native counterpart exists on Android for this method name β€” the Dart setter compiles and stores the handler cross-platform, but it is only ever invoked from `PurchaseConnectorPlugin.swift`; on Android it never fires. +- Entire feature is a no-op unless the app opted into the Purchase Connector at build time (`$AppsFlyerPurchaseConnector = true` in the Podfile, gating the `PurchaseConnector` podspec subspec and its `ENABLE_PURCHASE_CONNECTOR=1` flag) β€” otherwise `ios/PurchaseConnector/PurchaseConnectorPlugin.swift` isn't compiled into the app at all (see F-054). + +--- + +## Dependencies +```mermaid +flowchart LR + F052["F-052 Β· Purchase Connector: iOS Combined Validation Callback"]:::purchaseValidation -->|"requires configuration from"| F049["F-049 Β· Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-053-purchase-connector-google-play-data-models.md b/internal-docs/features/F-053-purchase-connector-google-play-data-models.md new file mode 100644 index 00000000..6769e88c --- /dev/null +++ b/internal-docs/features/F-053-purchase-connector-google-play-data-models.md @@ -0,0 +1,80 @@ +--- +id: F-053 +name: "Purchase Connector: Google Play Purchase/Subscription Data Models" +type: purchaseValidation +platform: android +status: active +last_verified: 2026-07-15 +depends_on: ["F-049", "F-051"] +--- + +## Business Purpose +Google's Play Developer API represents subscriptions and one-time in-app purchases as deep, nested JSON objects (cancellation reasons, price-change details, prepaid-plan windows, Subscribe-with-Google identity info, etc.). `SubscriptionPurchase`/`ProductPurchase` and their nested classes are the typed Dart mirror of that shape, generated with `json_annotation`/`json_serializable`, so app code consuming F-051's validation-result listeners gets strongly-typed fields instead of having to parse raw maps by hand. Without these models, `SubscriptionValidationResult`/`InAppPurchaseValidationResult` (F-051) would have to expose validation payloads as untyped `Map`, pushing all of Google's nested-schema knowledge onto every app developer. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Not a standalone entry point. These models are populated only as part of F-051's Android validation-result listener flow: whenever the native `purchase-connector` library's `SubscriptionPurchaseValidationResultListener`/`InAppPurchaseValidationResultListener` fires with a validation result, `SubscriptionPurchase`/`ProductPurchase` instances are constructed from the JSON that arrives over the method channel and attached to `SubscriptionValidationResult.subscriptionPurchase`/`InAppPurchaseValidationResult.productPurchase`. + +--- + +## Call Chain +Population happens inside F-051's response path β€” Kotlin native object β†’ JSON map β†’ Dart typed model: +``` +Google Play Billing subscription/purchase event β†’ PurchaseClient validates with AppsFlyer server + β†’ PurchaseClient.SubscriptionPurchaseValidationResultListener.onResponse(result: Map) [native purchase-connector lib] + β†’ ConnectorWrapper.SubscriptionPurchase.toJsonMap() / ConnectorWrapper.ProductPurchase.toJsonMap() [android/.../include-connector/ConnectorWrapper.kt] + (recursively maps every nested type: CanceledStateContext, ExternalAccountIdentifiers, SubscriptionPurchaseLineItem, OfferDetails, AutoRenewingPlan, Money, PausedStateContext, SubscribeWithGoogleInfo, TestPurchase, ...) + β†’ arsListener/viapListener β†’ methodChannel.invokeMethodOnUI(...) [AppsFlyerPurchaseConnector.kt] β†’ JSON string over "af-purchase-connector" channel + β†’ Dart _handleSubscriptionPurchaseValidationResultListenerOnResponse / _handleInAppValidationResultListenerOnResponse [lib/src/purchase_connector/purchase_connector.dart] + β†’ SubscriptionValidationResultMap.fromJson(...) / InAppPurchaseValidationResultMap.fromJson(...) + β†’ SubscriptionPurchase.fromJson(...) / ProductPurchase.fromJson(...) [lib/src/purchase_connector/models/subscription_purchase.dart, product_purchase.dart] + (generated `_$SubscriptionPurchaseFromJson`/`_$ProductPurchaseFromJson` in lib/appsflyer_sdk.g.dart) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/purchase_connector/models/subscription_purchase.dart` | `SubscriptionPurchase` + nested `@JsonSerializable()` classes: `CanceledStateContext`, `DeveloperInitiatedCancellation`, `ReplacementCancellation`, `SystemInitiatedCancellation`, `UserInitiatedCancellation`, `CancelSurveyResult`, `ExternalAccountIdentifiers`, `SubscriptionPurchaseLineItem`, `OfferDetails`, `AutoRenewingPlan`, `SubscriptionItemPriceChangeDetails`, `Money`, `DeferredItemReplacement`, `PrepaidPlan`, `PausedStateContext`, `SubscribeWithGoogleInfo`, `TestPurchase` | +| `lib/src/purchase_connector/models/product_purchase.dart` | `ProductPurchase` β€” flat model (kind, purchaseTimeMillis, purchaseState, consumptionState, developerPayload, orderId, purchaseType, acknowledgementState, purchaseToken, productId, quantity, obfuscatedExternalAccountId, obfuscatedExternalProfileId, regionCode) | +| `lib/appsflyer_sdk.g.dart` | Generated `fromJson`/`toJson` bodies for every class above (all model files are `part of appsflyer_sdk`, so `build_runner` emits one combined `.g.dart` at the library root rather than per-file) | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/ConnectorWrapper.kt` | `SubscriptionPurchase.toJsonMap()`, `ProductPurchase.toJsonMap()`, and one `toJsonMap()` extension per nested Kotlin type β€” the native side of the field-name contract | +| `lib/src/purchase_connector/models/subscription_validation_result.dart`, `models/in_app_purchase_validation_result.dart` | F-051's result wrappers that hold a `SubscriptionPurchase?`/`ProductPurchase?` β€” the only place these models are referenced | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | JSON produced by `ConnectorWrapper.kt`'s `toJsonMap()` family, delivered as a JSON string over the `af-purchase-connector` method channel as part of F-051's `onResponse` payloads. | +| **Output** | Strongly-typed `SubscriptionPurchase`/`ProductPurchase` Dart object graphs, exposed to the app only via `SubscriptionValidationResult.subscriptionPurchase` / `InAppPurchaseValidationResult.productPurchase` (F-051). | + +Field-name parity was verified directly against the Kotlin source: every key emitted by `ConnectorWrapper.kt`'s `toJsonMap()` functions (e.g. `"acknowledgementState"`, `"canceledStateContext"`, `"lineItems"`, `"subscribeWithGoogleInfo"`, `"purchaseTimeMillis"`, `"obfuscatedExternalAccountId"`, etc.) matches the corresponding Dart field name and the generated `_$...FromJson`/`_$...ToJson` keys in `lib/appsflyer_sdk.g.dart` exactly β€” no renaming or `@JsonKey` annotations are used anywhere in this model set. + +--- + +## Tests +No dedicated test found. Grepping `test/` for `SubscriptionPurchase`, `ProductPurchase`, or any of the nested type names (`OfferDetails`, `AutoRenewingPlan`, `Money`, `SubscribeWithGoogleInfo`, etc.) returns no matches. + +--- + +## Known Limitations +- These models only exist to be functional because of F-051's listener plumbing β€” and F-051's Android delivery path currently doesn't work (see F-051's documented method-name mismatch between the `#`-separated Dart constants and the `:`-separated strings Kotlin actually sends). Until that is fixed, these models are effectively dead code at runtime even though they compile and are fully wired. +- No custom `@JsonKey` mapping or manual value coercion exists anywhere in this model set β€” every field relies on an exact, case-sensitive key match between Kotlin's `toJsonMap()` and the Dart class; a rename on either side without updating the other would fail silently (`json['x'] as String` throws only if the key is present with the wrong type, but a missing/renamed key with a non-nullable field throws a `TypeError` deep inside `fromJson` with no context tying it back to Play Billing). +- Several fields (`purchaseTimeMillis`, `startTime`, `expiryTime`, `cancelTime`, etc.) are modeled as `String` even though they represent epoch milliseconds β€” no `DateTime` parsing is applied on either side, so callers must convert these themselves. +- `SubscriptionPurchase`/`ProductPurchase` mirror the Google Play Developer API schema at a point in time; if the native `purchase-connector:2.2.0` dependency (see `doc/PurchaseConnector.md`'s Billing Library 8.x note) adds or changes fields, these Dart models must be manually kept in sync β€” there is no schema-validation step in the build. +- This is Android/Google-Play-specific; there is no iOS equivalent typed model (F-052's `validationInfo` stays an untyped map). + +--- + +## Dependencies +```mermaid +flowchart LR + F053["F-053 Β· Purchase Connector: Google Play Data Models"]:::purchaseValidation -->|"requires configuration from"| F049["F-049 Β· Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation + F053 -->|"payload shape for"| F051["F-051 Β· Purchase Connector: Android Validation Result Listeners"]:::purchaseValidation + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-054-purchase-connector-build-time-opt-in.md b/internal-docs/features/F-054-purchase-connector-build-time-opt-in.md new file mode 100644 index 00000000..6badbc0e --- /dev/null +++ b/internal-docs/features/F-054-purchase-connector-build-time-opt-in.md @@ -0,0 +1,111 @@ +--- +id: F-054 +name: "Purchase Connector: Build-Time Opt-in (Android include/exclude variants)" +type: purchaseValidation +platform: both +status: active +last_verified: 2026-07-19 +depends_on: [F-060] +--- + +## Business Purpose +The Purchase Connector depends on the Google Play Billing Library (Android) and StoreKit (iOS) β€” libraries the plugin does not bundle, because most apps that don't sell in-app purchases/subscriptions shouldn't have to pull in billing dependencies just to use core attribution. This feature is the build-time switch that lets an app pull the real native Purchase Connector implementation into its build only if it explicitly opts in; apps that don't opt in get an inert stub instead. Without this switch, every consumer of the Flutter plugin would be forced to carry Play Billing Library / StoreKit purchase-connector native code (and satisfy their ProGuard/versioning constraints) even if they never call any Purchase Connector Dart API, which is unacceptable to plugins that just want attribution and deep linking. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Not a runtime trigger β€” this is a build-time decision made once per app project when it configures its Gradle/CocoaPods/SPM build: +- **Android**: `android/build.gradle` reads `project.findProperty('appsflyer.enable_purchase_connector')?.toBoolean() ?: false`. The app sets `appsflyer.enable_purchase_connector=true` in its own `gradle.properties`. +- **iOS, CocoaPods**: `ios/appsflyer_sdk.podspec` checks `if defined?($AppsFlyerPurchaseConnector)`. The app sets `$AppsFlyerPurchaseConnector = true` in its own `Podfile` before running `pod install`. +- **iOS, SPM (as of F-060 β€” Swift Package Manager Support)**: there is no opt-in mechanism at all. `ios/appsflyer_sdk/Package.swift` only ever declares the Core target; it has no knowledge of `PurchaseConnector` and no equivalent of the podspec's `pod_target_xcconfig` macro injection. An SPM-only integration always behaves as "not opted in" β€” see Known Limitations. + +--- + +## Call Chain +This feature has no runtime call chain β€” it is compile/build-time source-set and preprocessor branching: + +``` +Android (Gradle, evaluated at build configuration time): + android/build.gradle + def includeConnector = project.findProperty('appsflyer.enable_purchase_connector')?.toBoolean() ?: false + sourceSets.main.java.srcDirs += includeConnector + ? ['src/main/include-connector'] β†’ real AppsFlyerPurchaseConnector.kt + ConnectorWrapper.kt (Play Billing Library, PurchaseClient) + : ['src/main/exlude-connector'] β†’ stub AppsFlyerPurchaseConnector.kt (no MethodChannel registered) + +iOS (CocoaPods, evaluated at `pod install` time): + ios/appsflyer_sdk.podspec + if defined?($AppsFlyerPurchaseConnector) ("$AppsFlyerPurchaseConnector = true" set in app's Podfile) + s.default_subspecs = 'Core', 'PurchaseConnector' + subspec 'PurchaseConnector' β†’ depends on CocoaPods 'PurchaseConnector' pod + β†’ pod_target_xcconfig sets GCC_PREPROCESSOR_DEFINITIONS 'ENABLE_PURCHASE_CONNECTOR=1' + else + s.default_subspecs = 'Core' (PurchaseConnector subspec/pod not included at all) + + ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m (compiled per the xcconfig macro above): + #ifdef ENABLE_PURCHASE_CONNECTOR + #import "appsflyer_sdk/appsflyer_sdk-Swift.h" + #endif + ... + + (void)registerWithRegistrar:... + #ifdef ENABLE_PURCHASE_CONNECTOR + [PurchaseConnectorPlugin registerWithRegistrar:registrar]; + #endif + +iOS (SPM, resolved at `swift build`/`flutter build` time β€” third gate, added by F-060): + ios/appsflyer_sdk/Package.swift + targets: [.target(name: "appsflyer_sdk", ...)] β€” Core only, no PurchaseConnector target/product exists + β†’ ENABLE_PURCHASE_CONNECTOR is never defined for this target (SPM has no equivalent of CocoaPods' pod_target_xcconfig) + β†’ the same AppsflyerSdkPlugin.m above compiles with the #ifdef guard resolving false, identically to the CocoaPods not-opted-in path +``` + +--- + +## Files +| File | Role | +|------|------| +| `android/build.gradle` | Reads `appsflyer.enable_purchase_connector` Gradle property, switches `sourceSets.main.java.srcDirs` between the two variants | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | Real Android implementation: registers the `af-purchase-connector` MethodChannel and handles `configure`/`startObservingTransactions`/`stopObservingTransactions` | +| `android/src/main/include-connector/com/appsflyer/appsflyersdk/ConnectorWrapper.kt` | Wraps `PurchaseClient` (Play Billing Library) β€” only compiled in the include-connector variant | +| `android/src/main/exlude-connector/com/appsflyer/appsflyersdk/AppsFlyerPurchaseConnector.kt` | No-op stub: implements `FlutterPlugin` but registers no `MethodChannel` at all | +| `ios/appsflyer_sdk.podspec` | Defines the `PurchaseConnector` CocoaPods subspec conditionally on `$AppsFlyerPurchaseConnector`, and sets the `ENABLE_PURCHASE_CONNECTOR=1` preprocessor macro for that subspec only | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `#ifdef ENABLE_PURCHASE_CONNECTOR` guards both the Swift-bridging header import and the `[PurchaseConnectorPlugin registerWithRegistrar:registrar]` call | +| `ios/appsflyer_sdk/Package.swift` (added by F-060) | Declares only the Core target β€” has no PurchaseConnector target/product and no mechanism to define `ENABLE_PURCHASE_CONNECTOR`, so this gate is permanently "not opted in" for any SPM-only integration | +| `doc/PurchaseConnector.md` | App-facing opt-in instructions (`$AppsFlyerPurchaseConnector = true` in Podfile; `appsflyer.enable_purchase_connector=true` in gradle.properties) and an explicit "What Happens if You Use Dart Files Without Opting In?" section | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | Android: Gradle property `appsflyer.enable_purchase_connector` (string `"true"`/`"false"`, default `false`) set in the consuming app's `gradle.properties`. iOS: Ruby global `$AppsFlyerPurchaseConnector` set in the consuming app's `Podfile` before `pod install`; presence (not value) is what's checked (`defined?($AppsFlyerPurchaseConnector)`). | +| **Output** | Android: which `AppsFlyerPurchaseConnector.kt`/`ConnectorWrapper.kt` source set is compiled into the app's APK, and whether the `af-purchase-connector` MethodChannel gets a real handler. iOS: whether the `PurchaseConnector` pod/subspec and `ENABLE_PURCHASE_CONNECTOR` macro are present, which determines whether `PurchaseConnectorPlugin` is compiled and registered at all. | + +--- + +## Tests +No dedicated test found β€” this is a Gradle/CocoaPods build-configuration concern with no Dart or native unit test coverage; verifying it requires two full builds (opted-in vs. opted-out) rather than a unit test, which the repo's `test/appsflyer_sdk_test.dart` does not attempt. + +--- + +## Known Limitations +- The exclude-connector stub (`android/src/main/exlude-connector/.../AppsFlyerPurchaseConnector.kt`) is genuinely inert: it implements `FlutterPlugin.onAttachedToEngine`/`onDetachedFromEngine` as empty (`= Unit`) and never constructs a `MethodChannel` or sets a call handler. It does not throw and does not log a warning β€” it simply never responds. Any Dart call on the `af-purchase-connector` channel (`configure`, `startObservingTransactions`, etc.) in an app built without opting in will fail with Flutter's own `MissingPluginException`, not an AppsFlyer-authored error, making the failure mode confusing to diagnose (confirmed by reading the stub source directly). +- iOS has the same silent-gap behavior by omission rather than an explicit stub: if `$AppsFlyerPurchaseConnector` is undefined, the `PurchaseConnector` subspec/macro/registration are all compiled out, so `PurchaseConnectorPlugin` never registers a handler for `af-purchase-connector` either β€” same `MissingPluginException` outcome as Android, but reached via a completely different mechanism (absent Ruby global vs. an explicit empty Kotlin object), which is easy for engineers modifying one platform to forget applies to the other. +- **F-049 (Purchase Connector: Configuration & Lifecycle) and every other Purchase Connector Dart API are entirely meaningless without this feature being correctly opted into on both platforms** β€” the Dart-side classes (`PurchaseConnector`, `PurchaseConnectorConfiguration`, etc.) are always compiled into the plugin regardless of opt-in status, so an app can write code against them, pass static analysis, and still get runtime `MissingPluginException`s in production if it forgot the Podfile/gradle.properties step on either platform (`doc/PurchaseConnector.md` calls this out explicitly). +- The two opt-in mechanisms are asymmetric in strictness: Android checks a boolean value (`.toBoolean() ?: false`), so `appsflyer.enable_purchase_connector=false` or an unset/malformed property both cleanly resolve to "excluded." iOS checks mere *definedness* of `$AppsFlyerPurchaseConnector` (`defined?(...)`), so setting it to `false` in a Podfile still counts as "opted in" (`if defined?($AppsFlyerPurchaseConnector)` is true regardless of the assigned value) β€” a plausible copy-paste mistake (`$AppsFlyerPurchaseConnector = false` intending to disable it) silently enables the feature. +- **As of F-060 (Swift Package Manager Support), this gate has a third path with no opt-in mechanism at all**: an app integrated via SPM cannot enable Purchase Connector under any configuration this release β€” `ios/appsflyer_sdk/Package.swift` never defines `ENABLE_PURCHASE_CONNECTOR`, so the `#ifdef` guard always resolves false. Calling any Purchase Connector Dart API from an SPM-only integration fails with the same generic `MissingPluginException` described above for the CocoaPods not-opted-in case β€” this is not a new failure mode, but it is a third, permanent path to the same confusing outcome, not a temporary misconfiguration a developer can fix by setting a flag. Apps that need Purchase Connector must stay on CocoaPods until flutter/flutter#161182 (Flutter's own plugin tooling lacking conditional-compilation support) is resolved β€” see F-060 and `docs/researches/R-001-spm-support.md` for why SPM Package Traits do not currently offer a workaround. + +--- + +## Dependencies +```mermaid +flowchart LR + F054["F-054 Β· Purchase Connector: Build-Time Opt-in"]:::purchaseValidation + F049["F-049 Β· Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation + F060["F-060 Β· Swift Package Manager Support"]:::sdkCore + F054 -->|"gates compilation/registration of"| F049 + F060 -->|"adds a third, permanently-excluded iOS path to"| F054 + classDef purchaseValidation fill:#F59F00,color:#fff + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-055-purchase-connector-missing-configuration-guard.md b/internal-docs/features/F-055-purchase-connector-missing-configuration-guard.md new file mode 100644 index 00000000..758c51ab --- /dev/null +++ b/internal-docs/features/F-055-purchase-connector-missing-configuration-guard.md @@ -0,0 +1,73 @@ +--- +id: F-055 +name: Missing-Configuration Guard for Purchase Connector +type: purchaseValidation +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-049"] +--- + +## Business Purpose +`PurchaseConnector` is a Dart-side singleton that must be seeded with a `PurchaseConnectorConfiguration` (log subscriptions/in-apps, sandbox, StoreKit version) the very first time it is created; every later use of the connector β€” starting/stopping transaction observation, registering validation listeners β€” assumes that configuration already exists. If an app called for the singleton before ever supplying a config (e.g. a widget deep in the app calls `PurchaseConnector()` with no args, expecting an already-configured instance from elsewhere, but app startup order was wrong), there would be no configuration to build the native connector from. This guard turns that programmer error into an immediate, typed Dart exception (`MissingConfigurationException`) at the call site instead of a null/uninitialized native connector failing silently or crashing later when a purchase actually occurs β€” which is far harder to trace back to a missing `configure()` call. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Runs synchronously inside the `_PurchaseConnectorImpl` factory constructor every time `PurchaseConnector({config})` is invoked. It throws specifically when `_instance == null && config == null` β€” i.e. no singleton has been created yet, and the caller did not supply a `PurchaseConnectorConfiguration` on this call either. + +--- + +## Call Chain +``` +PurchaseConnector({config}) [lib/src/purchase_connector/purchase_connector.dart] + β†’ factory _PurchaseConnectorImpl({config}) + β†’ if (_instance == null && config == null) + β†’ throw MissingConfigurationException() [lib/src/purchase_connector/missing_configuration_exception.dart] + (message defaults to AppsflyerConstants.MISSING_CONFIGURATION_EXCEPTION_MSG + = "Configuration is missing. Call PurchaseConnector.configure() first.") +``` +No native/method-channel hop β€” this guard is pure Dart and fires before any `MethodChannel` is even constructed. + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/purchase_connector/missing_configuration_exception.dart` | Defines `MissingConfigurationException implements Exception`, carrying a `message` field and a `toString()` override (`'ConfigurationException: $message'`) | +| `lib/src/purchase_connector/purchase_connector.dart` | `_PurchaseConnectorImpl` factory constructor β€” the sole place this exception is thrown (`if (_instance == null && config == null) throw MissingConfigurationException();`) | +| `lib/src/appsflyer_constants.dart` | `MISSING_CONFIGURATION_EXCEPTION_MSG` string constant used as the default message | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | Implicit: the current state of the static `_PurchaseConnectorImpl._instance` field (null or not) and whether the caller passed a non-null `config` argument to the `PurchaseConnector(...)` factory. | +| **Output** | A thrown `MissingConfigurationException` (uncaught by the plugin β€” propagates to the app's call site) whose `toString()` yields `"ConfigurationException: Configuration is missing. Call PurchaseConnector.configure() first."`. If the guard condition is false, output is instead a valid `_PurchaseConnectorImpl` instance (see F-049). | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart` never imports or references `PurchaseConnector`, `_PurchaseConnectorImpl`, or `MissingConfigurationException`, so neither the throw path nor the singleton-reuse path is covered by CI. + +--- + +## Known Limitations +- No test coverage β€” a future refactor of the singleton logic (e.g. changing the `_instance == null && config == null` condition) could silently stop throwing, or start throwing on valid calls, without any CI signal. +- The exception message text ("Call `PurchaseConnector.configure() first`") references a `configure()` method that does not exist in the Dart API β€” configuration is actually supplied via the `PurchaseConnector({config})` factory constructor itself, not a separate `configure()` call. This is a documentation/message mismatch that could mislead a developer debugging the exception (there is a native-side `"configure"` MethodChannel method name, but it is not a Dart-callable API). +- The guard only protects the *first* construction. Once any instance exists, subsequent calls to `PurchaseConnector(config: ...)` with a *different* config are not guarded at all β€” they are silently ignored (see F-049 Known Limitations), which is a related but distinct gap this feature does not cover. +- Because the check is purely on Dart-side static state (`_instance`), it has no knowledge of whether the native Purchase Connector was actually compiled into the build (see F-054). An app could pass a valid config and never hit this guard, yet still get a native `MissingPluginException` on the very next call if it never opted in at build time β€” this guard cannot detect or report that separate failure mode. + +--- + +## Dependencies +```mermaid +flowchart LR + F055["F-055 Β· Missing-Configuration Guard for Purchase Connector"]:::purchaseValidation + F049["F-049 Β· Purchase Connector: Configuration & Lifecycle"]:::purchaseValidation + F055 -->|"guards first construction of"| F049 + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/F-056-app-invite-link-onelink-id-init-time.md b/internal-docs/features/F-056-app-invite-link-onelink-id-init-time.md new file mode 100644 index 00000000..854faa07 --- /dev/null +++ b/internal-docs/features/F-056-app-invite-link-onelink-id-init-time.md @@ -0,0 +1,76 @@ +--- +id: F-056 +name: App Invite Link OneLink ID (init-time) +type: oneLinkAndGrowth +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-028"] +--- + +## Business Purpose +Apps that already know their invite-link OneLink ID at build/config time (rather than resolving it dynamically at runtime) want to configure it once, as part of the same `AppsFlyerOptions`/init-options object used to configure the dev key, app ID, and other startup flags β€” avoiding a separate `setAppInviteOneLinkID` (F-028) call after `initSdk()`. The `appInviteOneLink` init option sets this same underlying native OneLink ID at SDK-initialization time, so `generateInviteLink` (F-027) has a base link ready as soon as the SDK starts. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Runs once, during `initSdk()`, whenever the host app constructed its `AppsFlyerOptions` (or the equivalent options `Map`) with a non-null `appInviteOneLink` value. + +--- + +## Call Chain +``` +AppsFlyerOptions(appInviteOneLink: "...") [lib/src/appsflyer_options.dart] + β†’ AppsflyerSdk.initSdk() [lib/src/appsflyer_sdk.dart] + β†’ _validateAFOptions(afOptions) / _validateMapOptions(mapOptions) [lib/src/appsflyer_sdk.dart] + β†’ validatedOptions[AppsflyerConstants.APP_INVITE_ONE_LINK] = appInviteOneLink + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("initSdk") β†’ initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ call.argument(AppsFlyerConstants.AF_APP_INVITE_ONE_LINK) β†’ AppsFlyerLib.getInstance().setAppInviteOneLink(appInviteOneLink) (only if non-null) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") β†’ initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ call.arguments[afInviteOneLink] β†’ [AppsFlyerLib shared].appInviteOneLinkID = appInviteOneLink (only if non-nil and not NSNull) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.appInviteOneLink` β€” optional `String?` init-time field | +| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions()` (lines ~56-61) and `_validateMapOptions()` (lines ~111-123) β€” copy `appInviteOneLink` into `validatedOptions[AppsflyerConstants.APP_INVITE_ONE_LINK]` under the wire key `"appInviteOneLink"`; `initSdk()` sends it as part of the `"initSdk"` method-channel call | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` (~line 1100) reads `AppsFlyerConstants.AF_APP_INVITE_ONE_LINK` and calls `AppsFlyerLib.getInstance().setAppInviteOneLink(appInviteOneLink)` if non-null, **after** `instance.init(...)` but before `instance.start(activity)` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` (~line 831) reads `afInviteOneLink` (`"appInviteOneLink"`) and sets `[AppsFlyerLib shared].appInviteOneLinkID` if non-nil and not `NSNull` | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `AppsFlyerOptions.appInviteOneLink` (`String?`) or `mapOptions["appInviteOneLink"]`, consumed only during `initSdk()` | +| **Output** | Sets the same underlying native OneLink ID property that `setAppInviteOneLinkID` (F-028) sets at runtime (`AppsFlyerLib.getInstance()` on Android, `[AppsFlyerLib shared].appInviteOneLinkID` on iOS) β€” no dedicated success/failure callback exists for the init-time path | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` (line 93) exercises the general `initSdk()` path using `mapOptions: {'afDevKey': 'sdfhj2342cx'}` (set in `setUp()`, line 19) β€” no test sets or asserts `appInviteOneLink`/`APP_INVITE_ONE_LINK` specifically, on either the Dart validation logic or either native handler. + +--- + +## Known Limitations +- **Assert-only validation**: both `_validateAFOptions` and `_validateMapOptions` in `lib/src/appsflyer_sdk.dart` only `assert(appInviteOneLink is String)` when non-null β€” `assert` is stripped in release (profile/release) Flutter builds, so a wrong type passed via the untyped `Map` init path would not be caught outside debug mode. +- **Silently overwritten by a later runtime call**: because F-056 (init-time) and F-028 (`setAppInviteOneLinkID`, runtime) both write to the exact same native property, calling `setAppInviteOneLinkID` after `initSdk()` completes silently overrides whatever was set via the `appInviteOneLink` init option, with no warning of the override. +- **Android sets it after `init()` but the codebase doesn't document why**: `AppsFlyerLib.getInstance().setAppInviteOneLink(appInviteOneLink)` is called after `instance.init(afDevKey, gcdListener, mContext)` and before `instance.start(activity)`; the ordering relative to `start()` is load-bearing for the native SDK but is not asserted or tested here. +- No way to detect, from Dart, whether the init-time `appInviteOneLink` value was actually applied by the native SDK (no callback/confirmation, unlike the explicit `setAppInviteOneLinkID` callback in F-028). + +--- + +## Dependencies +```mermaid +flowchart LR + F056["F-056 Β· App Invite Link OneLink ID (init-time)"]:::oneLinkAndGrowth + F028["F-028 Β· App Invite OneLink ID Configuration"]:::oneLinkAndGrowth + F056 -->|"shares same native OneLink-ID property, last write wins"| F028 + classDef oneLinkAndGrowth fill:#7048E8,color:#fff +``` diff --git a/internal-docs/features/F-057-asa-collection-optout.md b/internal-docs/features/F-057-asa-collection-optout.md new file mode 100644 index 00000000..d7d526d3 --- /dev/null +++ b/internal-docs/features/F-057-asa-collection-optout.md @@ -0,0 +1,78 @@ +--- +id: F-057 +name: ASA (Apple Search Ads) Collection Opt-out +type: sdkCore +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: ["F-001"] +--- + +## Business Purpose +The native iOS SDK automatically queries Apple's Search Ads Attribution API (ASA) to enrich attribution data for installs originating from Apple Search Ads campaigns. Some apps β€” for privacy/compliance reasons, or because they don't run Apple Search Ads campaigns and want to avoid the extra API call/data collection β€” need to opt out of this automatic collection at init time. `disableCollectASA` is the init-time switch that turns it off before the SDK starts. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Set once by the host app as part of `AppsFlyerOptions` (or the raw options `Map`) passed to the `AppsflyerSdk` constructor, and applied during `initSdk()`/`initSdkWithCall:`, before the SDK starts. iOS only β€” read and applied only when `Platform.isIOS` on the Dart side, and only has a corresponding native code path on iOS. + +--- + +## Call Chain +``` +AppsFlyerOptions(disableCollectASA: true) [lib/src/appsflyer_options.dart] + β†’ AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] + β†’ _validateAFOptions(options) / _validateMapOptions(options) + β†’ if Platform.isIOS is NOT required here β€” value is copied unconditionally on both platforms: + validatedOptions[AppsflyerConstants.DISABLE_COLLECT_ASA] = options.disableCollectASA (line 63-66 / 125-128) + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") β†’ initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ disableCollectASA = call.arguments[afDisableCollectASA] (as NSNumber β†’ BOOL) (line 836-840) + β†’ [AppsFlyerLib shared].disableCollectASA = disableCollectASA (line 848) + β†’ Android: AppsflyerSdkPlugin.initSdk(call, result) β€” value is never read; no `DISABLE_COLLECT_ASA` + constant exists in `AppsFlyerConstants.java` and Apple Search Ads has no Android equivalent +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.disableCollectASA` (`bool?`, optional named constructor param) | +| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions` / `_validateMapOptions` β€” copies `disableCollectASA` into the validated options map unconditionally (no `Platform.isIOS` guard on the Dart validation side) if non-null | +| `lib/src/appsflyer_constants.dart` | `DISABLE_COLLECT_ASA = "disableCollectASA"` β€” shared Dart↔native key | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afDisableCollectASA @"disableCollectASA"` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β€” parses the flag and sets `[AppsFlyerLib shared].disableCollectASA` (lines 836–848) | +| `doc/BasicIntegration.md`, `doc/API.md` | Document `disableCollectASA` as "Opt-out of the Apple Search Ads attributions" | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `disableCollectASA` (`bool?`) via `AppsFlyerOptions` or the equivalent Map key, read at init time only | +| **Output** | `void` β€” sets a property on the native iOS SDK singleton before `start`; no confirmation returned to Dart. On Android the value is silently discarded. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` test only asserts that the `"initSdk"` method is invoked; it does not construct `AppsFlyerOptions` with `disableCollectASA` set, nor assert the resulting map contains the key, nor exercise the iOS-only native path (Dart `flutter test` runs on the host OS, not `Platform.isIOS`). + +--- + +## Known Limitations +- Android-side handling doesn't exist at all: there is no `DISABLE_COLLECT_ASA` constant in `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` and the Android `initSdk(call, result)` never reads the key β€” this is expected (ASA is an Apple-only concept) but is not documented anywhere as an explicit no-op; a host app setting `disableCollectASA: true` gets no feedback that it had no effect on Android. +- Dart-side validation (`_validateAFOptions`) copies `disableCollectASA` into `validatedOptions` unconditionally (not gated behind `Platform.isIOS` like `timeToWaitForATTUserAuthorization` and `appId` are) β€” inconsistent with how the same method gates other iOS-only fields. +- No getter exists to confirm whether ASA collection is currently disabled after init. +- One-directional: once set (or left at the default `NO`/false) at init time, there is no runtime API in this plugin to toggle it after the SDK has started. + +--- + +## Dependencies +```mermaid +flowchart LR + F057["F-057 Β· ASA Collection Opt-out"]:::sdkCore -->|"applied only during"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-058-att-authorization-wait-timeout.md b/internal-docs/features/F-058-att-authorization-wait-timeout.md new file mode 100644 index 00000000..a467a2c0 --- /dev/null +++ b/internal-docs/features/F-058-att-authorization-wait-timeout.md @@ -0,0 +1,80 @@ +--- +id: F-058 +name: ATT Authorization Wait Timeout (iOS) +type: sdkCore +platform: ios +status: active +last_verified: 2026-07-15 +depends_on: ["F-001"] +--- + +## Business Purpose +Since iOS 14.5, apps must show Apple's App Tracking Transparency (ATT) prompt before collecting the IDFA. If the AppsFlyer SDK starts (and fires its first session/attribution request) before the user responds to that prompt, it may miss the IDFA and under-report attribution. `timeToWaitForATTUserAuthorization` lets the host app delay the SDK's `start()` call for up to N seconds so it can wait for the user to accept, decline, or time out on the consent dialog before the first session is sent β€” improving IDFA-based attribution accuracy without requiring the app to manually gate SDK start behind a callback. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Set once by the host app as part of `AppsFlyerOptions` (or the raw options `Map`), read only on iOS (`Platform.isIOS`), and applied inside `initSdkWithCall:` before the SDK's `start` call. + +--- + +## Call Chain +``` +AppsFlyerOptions(timeToWaitForATTUserAuthorization: 50.0) [lib/src/appsflyer_options.dart] + β†’ AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] + β†’ _validateAFOptions(options) / _validateMapOptions(options) + β†’ if (Platform.isIOS) { assert(value is double); + validatedOptions[AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION] = value } (lines 76-85 / 137-148) + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") β†’ initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ timeToWaitForATTUserAuthorization = call.arguments[afTimeToWaitForATTUserAuthorization] doubleValue (line 796) + β†’ if (timeToWaitForATTUserAuthorization != 0) { + [[AppsFlyerLib shared] waitForATTUserAuthorizationWithTimeoutInterval:timeToWaitForATTUserAuthorization] + } (lines 867-869) + β†’ [[AppsFlyerLib shared] start] (unless manualStart) (line 873) + β†’ Android: value is never read β€” no Android equivalent exists (ATT is an iOS-only framework) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.timeToWaitForATTUserAuthorization` (`double?`, optional named constructor param) | +| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions` / `_validateMapOptions` β€” reads the value **only** when `Platform.isIOS`, asserts it is a `double`, copies into the validated options map | +| `lib/src/appsflyer_constants.dart` | `AF_TIME_TO_WAIT_FOR_ATT_USER_AUTHORIZATION = "timeToWaitForATTUserAuthorization"` β€” shared Dart↔native key | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afTimeToWaitForATTUserAuthorization @"timeToWaitForATTUserAuthorization"` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β€” parses the interval and calls `waitForATTUserAuthorizationWithTimeoutInterval:` before `start` (lines 796, 860-869) | +| `doc/BasicIntegration.md`, `doc/AdvancedAPI.md`, `doc/Guides.md`, `doc/API.md` | Document the option as delaying SDK start "for x seconds until the user either accepts the consent dialog, declines it, or the timer runs out" | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `timeToWaitForATTUserAuthorization` (`double?`, seconds) via `AppsFlyerOptions` or the equivalent Map key; only read/applied when `Platform.isIOS` | +| **Output** | `void` β€” delays the native SDK's internal `start()`/first session dispatch by up to the given interval (or until ATT authorization resolves, whichever comes first); no value or confirmation returned to Dart. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` test does not set `timeToWaitForATTUserAuthorization` in its options and, because Dart tests do not run with `Platform.isIOS == true`, the entire `if (Platform.isIOS) { ... }` validation branch (including the `assert(timeToWaitForATTUserAuthorization is double)` check and the iOS App ID regex validation alongside it) is untested. + +--- + +## Known Limitations +- Android has no equivalent: the option is silently ignored on Android (no `Platform.isIOS` guard exists on the *native* Android side because the key is simply never sent β€” the guard lives entirely in Dart's `_validateAFOptions`/`_validateMapOptions`). A host app relying on `Platform.isIOS` checks elsewhere but forgetting one here would have no functional impact, since Android's `initSdk` never looks for this key at all. +- iOS's `AppsflyerSdkPlugin.m` contains a large commented-out block (lines ~860-865) that shows an earlier `respondsToSelector:`/`objc_msgSend` based implementation of this same call, superseded by the direct `waitForATTUserAuthorizationWithTimeoutInterval:` call β€” dead code left in place, mildly confusing when reading the file. +- A value of exactly `0` is treated as "not set" (`if (timeToWaitForATTUserAuthorization != 0)`), so a host app cannot explicitly pass `0.0` to mean "no wait" versus simply omitting the option β€” both behave identically. +- The Dart-side `assert(timeToWaitForATTUserAuthorization is double)` is stripped in release builds, so passing a non-double dynamic value (e.g. via the raw `Map` options path) would silently misbehave in production rather than failing fast. + +--- + +## Dependencies +```mermaid +flowchart LR + F058["F-058 Β· ATT Authorization Wait Timeout (iOS)"]:::sdkCore -->|"applied only during"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-059-debug-logging-toggle.md b/internal-docs/features/F-059-debug-logging-toggle.md new file mode 100644 index 00000000..453bccb0 --- /dev/null +++ b/internal-docs/features/F-059-debug-logging-toggle.md @@ -0,0 +1,82 @@ +--- +id: F-059 +name: Debug Logging Toggle +type: sdkCore +platform: both +status: active +last_verified: 2026-07-15 +depends_on: ["F-001"] +--- + +## Business Purpose +During integration and QA, developers need verbose native SDK logging (request/response payloads, session lifecycle, error detail) to diagnose why attribution or events aren't showing up as expected. `showDebug` is the init-time switch that turns this on. AppsFlyer explicitly warns this must not ship to production, since verbose logs can leak internal request data into device logs. + +> TODO: enrich from product specs β€” provide a Notion database URL and re-run Phase 4 to fill this automatically. + +--- + +## Trigger +Set once by the host app as part of `AppsFlyerOptions` (or the raw options `Map`), defaulting to `false`, and applied during `initSdk()`/`initSdkWithCall:` on both platforms before the native SDK starts. + +--- + +## Call Chain +``` +AppsFlyerOptions(showDebug: true) [lib/src/appsflyer_options.dart] + β†’ AppsflyerSdk.initSdk(...) [lib/src/appsflyer_sdk.dart] + β†’ _validateAFOptions(options) / _validateMapOptions(options) + β†’ validatedOptions[AF_IS_DEBUG] = options.showDebug ?? false (line 94-96 / 158-161) + β†’ _methodChannel.invokeMethod("initSdk", validatedOptions) + β†’ Android: AppsflyerSdkPlugin.onMethodCall("initSdk") β†’ initSdk(call, result) [android/.../AppsflyerSdkPlugin.java] + β†’ isDebug = call.argument(AF_IS_DEBUG) (line 1087) + β†’ if (isDebug) { instance.setLogLevel(AFLogger.LogLevel.DEBUG); + instance.setDebugLog(true); } + else { instance.setDebugLog(false); } (lines 1088-1093) + β†’ iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") β†’ initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m] + β†’ isDebugValue = call.arguments[afIsDebug] (line 805) + β†’ [AppsFlyerLib shared].isDebug = isDebug (line 813) +``` + +--- + +## Files +| File | Role | +|------|------| +| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions.showDebug` (`bool`, defaults to `false`) | +| `lib/src/appsflyer_sdk.dart` | `_validateAFOptions` / `_validateMapOptions` β€” always writes `AF_IS_DEBUG` into the validated options map, defaulting to `false` if unset | +| `lib/src/appsflyer_constants.dart` | `AF_IS_DEBUG = "isDebug"` β€” shared Dart↔native key | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` β€” toggles `AppsFlyerLib.getInstance().setLogLevel(...)` and `.setDebugLog(...)` (lines 1087-1093) | +| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `AF_IS_DEBUG = "isDebug"` β€” native Android mirror of the Dart key | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define afIsDebug @"isDebug"` | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β€” sets `[AppsFlyerLib shared].isDebug` directly (lines 805, 813) | +| `doc/BasicIntegration.md`, `doc/API.md`, `doc/Testing.md` | Document `showDebug` and warn "do not release to production with this parameter set to `true`" | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | `showDebug` (`bool`, defaults to `false`) via `AppsFlyerOptions` or the equivalent Map key | +| **Output** | `void` β€” toggles native SDK verbose logging as a side effect of init; no confirmation returned to Dart. | + +--- + +## Tests +No dedicated test found. `test/appsflyer_sdk_test.dart`'s `check initSdk call` test uses `mapOptions: {'afDevKey': ...}` with no `isDebug` key set, so it only exercises the default-`false` path implicitly and never asserts the value of `AF_IS_DEBUG` in the resulting arguments map, nor exercises the `true` branch on either platform. + +--- + +## Known Limitations +- Android and iOS implement the flag differently: Android makes two separate native calls when enabling (`setLogLevel(AFLogger.LogLevel.DEBUG)` **and** `setDebugLog(true)`) but only one call when disabling (`setDebugLog(false)` β€” the log level is never explicitly reset), while iOS sets a single `isDebug` property that presumably controls both internally. This asymmetry is not tested and could produce subtly different logging verbosity between platforms if the native SDKs' internal defaults ever diverge. +- No public Dart getter exists to read back the current debug-logging state after init. +- The Dart-side null-coalescing comment (`// ignore: unnecessary_null_comparison`) on `options.showDebug != null` in `_validateAFOptions` suggests this check is dead code, since `showDebug` is a non-nullable `bool` with a default value in `AppsFlyerOptions` and can never be `null` at that call site. +- This is an init-time-only toggle β€” there is no runtime API in this plugin to turn debug logging on/off after `initSdk()` has already run. + +--- + +## Dependencies +```mermaid +flowchart LR + F059["F-059 Β· Debug Logging Toggle"]:::sdkCore -->|"applied only during"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore + classDef sdkCore fill:#4C6EF5,color:#fff +``` diff --git a/internal-docs/features/F-060-swift-package-manager-support.md b/internal-docs/features/F-060-swift-package-manager-support.md new file mode 100644 index 00000000..617fb980 --- /dev/null +++ b/internal-docs/features/F-060-swift-package-manager-support.md @@ -0,0 +1,104 @@ +--- +id: F-060 +name: "Swift Package Manager (SPM) Support (Core, iOS)" +type: sdkCore +platform: ios +status: active +last_verified: 2026-07-19 +depends_on: [] +--- + +## Business Purpose +Flutter 3.44+ makes Swift Package Manager the default iOS integration mechanism, and CocoaPods trunk goes read-only on December 2, 2026 β€” after that date, this plugin could no longer publish new CocoaPods releases at all, and any app on Flutter 3.44+ that hadn't migrated would hit a hard build error instead of today's build warning. Without this feature, every consumer of the plugin would eventually be forced onto an unsupported distribution path, and competing attribution SDKs (Adjust, Singular) that already support SPM would have a real integration advantage. This feature adds a `Package.swift` manifest for the Core integration so apps can adopt SPM today, while leaving CocoaPods fully intact for apps that aren't ready to migrate or that need Purchase Connector (see Known Limitations). + +Ticket: DELIVERY-125462. + +--- + +## Trigger +Not a runtime trigger β€” this is a build-time/distribution-mechanism choice made once per consuming app project: +- **SPM path**: the app either runs on Flutter 3.44+ (SPM is the default) or explicitly opts in on earlier 3.24+ versions via `flutter config --enable-swift-package-manager`. Flutter's own tooling then discovers `ios/appsflyer_sdk/Package.swift` at its conventional path β€” no marker or flag is required in the podspec to signal SPM availability. +- **CocoaPods path**: unchanged β€” apps that run `pod install` continue to resolve via `ios/appsflyer_sdk.podspec` exactly as before. + +--- + +## Call Chain +This feature has no runtime call chain β€” it is a build-time source-tree and manifest change: + +``` +Shared source tree (used by both paths, single copy β€” no duplication): + ios/appsflyer_sdk/Sources/appsflyer_sdk/ + AppsflyerSdkPlugin.m (moved from ios/Classes/, content unmodified) + AppsFlyerAttribution.m (moved, unmodified) + AppsFlyerStreamHandler.m (moved, unmodified) + include/appsflyer_sdk/ + AppsflyerSdkPlugin.h (moved, unmodified β€” public header, pluginClass entry point) + AppsFlyerAttribution.h + AppsFlyerStreamHandler.h + FlutterAppDelegate+AppsFlyerStreamHandler.h + +SPM path (resolved by `flutter build`/`swift build` at build configuration time): + ios/appsflyer_sdk/Package.swift + β†’ target "appsflyer_sdk" depends on product "AppsFlyerLib" from AppsFlyerFramework, pinned exactly to 6.18.0 + β†’ compiles the shared Sources/ tree above as a ClangTarget, iOS 12.0 minimum + β†’ does NOT reference ios/PurchaseConnector/ at all β€” no PurchaseConnector target/product exists in this manifest + +CocoaPods path (resolved by `pod install` at install time, unchanged behavior): + ios/appsflyer_sdk.podspec + subspec 'Core' β†’ source_files/public_header_files repointed at the same shared Sources/ tree above + subspec 'PurchaseConnector' β†’ untouched, still points at ios/PurchaseConnector/ (unmoved) +``` + +--- + +## Files +| File | Role | +|------|------| +| `ios/appsflyer_sdk/Package.swift` | New SPM manifest. `swift-tools-version:5.9` (Xcode 15.0+), `platforms: [.iOS("12.0")]` (matches the podspec's existing deployment target). Declares one product/target depending on `AppsFlyerFramework`'s `AppsFlyerLib` product, pinned `.exact("6.18.0")`, matching the podspec's exact CocoaPods pin. | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/*.m` | Core implementation files, moved verbatim from `ios/Classes/` via `git mv` (confirmed zero content diff) β€” now the single shared source tree for both CocoaPods and SPM. | +| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/*.h` | Public headers, moved verbatim from `ios/Classes/` β€” `AppsflyerSdkPlugin.h` is where `pluginClass: AppsflyerSdkPlugin` (declared in `pubspec.yaml`, unchanged) resolves from in both integration paths. | +| `ios/appsflyer_sdk.podspec` | `Core` subspec's `source_files`/`public_header_files` repointed to the new shared path; `PurchaseConnector` subspec is untouched. No marker added to declare SPM availability β€” Flutter's tooling detects it purely by the presence of `Package.swift` at the conventional path. | +| `ios/.gitignore` | Added `.build/` and `.swiftpm/` β€” local SPM resolution/build artifacts that must not be committed. | +| `CHANGELOG.md` | Documents SPM support added under the 6.18.0 entry, Purchase Connector's continued CocoaPods-only status, and a link to flutter/flutter#161182. | + +--- + +## Input / Output +| | | +|--|--| +| **Input** | Which iOS integration mechanism the consuming app's Flutter tooling selects: SPM (default on Flutter 3.44+, opt-in via `flutter config --enable-swift-package-manager` on 3.24–3.43) or CocoaPods (`pod install`, unchanged). Nothing in `pubspec.yaml` changes to select this β€” it's entirely driven by the app's own Flutter/Xcode configuration. | +| **Output** | Which build system compiles the Core native code and links `AppsFlyerFramework` into the app: Swift Package Manager resolving `AppsFlyerLib` directly from GitHub, or CocoaPods resolving the `AppsFlyerFramework` pod as before. Either path produces the same compiled Core behavior β€” same source files, same public API surface. | + +--- + +## Tests +No dedicated automated test β€” this is a build-configuration/distribution-mechanism concern with no Dart or native runtime logic change, the same category as F-054 (Purchase Connector: Build-Time Opt-in), which sets the precedent that this class of change is verified via full builds rather than unit tests. Verification performed for this change: +- `swift package describe` β€” genuine dependency resolution against the live `AppsFlyerFramework` GitHub repository, confirming the manifest resolves product `AppsFlyerLib` at `Exact: 6.18.0` (corrected from an earlier `from:` range pin during review β€” see Known Limitations) and picks up all 3 Core `.m` sources correctly. +- `pod spec lint --quick --allow-warnings` β€” passed, confirming the podspec's repointed `source_files`/`public_header_files` globs resolve correctly against the moved tree. +- `flutter test test` β€” all 38 existing Dart tests pass unaffected (this change touches only iOS native file locations and build manifests, not Dart code). +- **Real-device iOS E2E, dispatched via GitHub Actions with real credentials β€” all 6 scenario phases PASS in each:** + - SPM, Core only, `.exact("6.18.0")` pin β€” [run 30191649705](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/30191649705). `getSDKVersion` confirmed resolving `6.18.0`, not a drifted patch release (an earlier run against the pre-fix `from:` pin had resolved `6.18.1` β€” see Known Limitations). **This is the only SPM configuration we recommend or support.** + - Pure CocoaPods, Core + PurchaseConnector, SPM explicitly disabled β€” [run 29901950273](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/29901950273). **This is the only supported configuration for apps using Purchase Connector.** + - SPM Core + CocoaPods PurchaseConnector configured simultaneously β€” [run 29848672331](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/29848672331). This run only demonstrates the app builds and links without a crash when both are configured at once (no duplicate-symbol failure) β€” it does **not** demonstrate Purchase Connector actually functions in this configuration, and CI's own logs suggest Flutter's tooling may silently drop the CocoaPods `PurchaseConnector` pod entirely once it detects the plugin has a `Package.swift`. **This combination is explicitly not supported or recommended** β€” see doc/Installation.md and doc/PurchaseConnector.md, both updated to state that apps using Purchase Connector must not enable SPM for this plugin at all. + +> **Remaining gap**: whether SPM+PurchaseConnector configured simultaneously silently drops Purchase Connector or actively fails with `MissingPluginException` has not been conclusively confirmed either way β€” investigation was inconclusive (see above) and this combination is now explicitly unsupported regardless of the answer, so it was not pursued further. + +--- + +## Known Limitations +- **Purchase Connector is not available via SPM this release, with no opt-in mechanism at all.** `Package.swift` never references `ios/PurchaseConnector/` and has no equivalent of the podspec's `pod_target_xcconfig` macro injection, so `ENABLE_PURCHASE_CONNECTOR` is never defined for an SPM build under any configuration. Calling a Purchase Connector Dart API from an SPM-only integration fails with the same generic Flutter `MissingPluginException` that F-054 already documents for the CocoaPods not-opted-in case β€” this is not a new or worse failure mode, but it is a third, permanent path to it (not something a developer can fix by setting a flag, unlike the other two paths). Apps that need Purchase Connector must stay on CocoaPods until flutter/flutter#161182 is resolved. +- **SPM and Purchase Connector cannot be combined, even though nothing prevents an app from *configuring* both at once.** An app can set `$AppsFlyerPurchaseConnector = true` in its Podfile while also having SPM enabled β€” this doesn't crash or error at build time (verified: [run 29848672331](https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/actions/runs/29848672331) built and linked successfully) β€” but CI's logs suggest Flutter's tooling may silently drop the CocoaPods `PurchaseConnector` pod once it decides the plugin is SPM-eligible, meaning the feature may silently not be present despite looking configured. This was not conclusively resolved either way; instead of continuing to investigate, this combination is explicitly documented as unsupported (`doc/Installation.md`, `doc/PurchaseConnector.md`): **apps using Purchase Connector must not enable SPM for this plugin at all.** +- **flutter/flutter#161182 (Flutter's own plugin tooling lacking conditional-compilation support under SPM) is the real blocker**, not a SwiftPM limitation β€” investigated during research (`internal-docs/researches/R-001-spm-support.md`), including whether SwiftPM Package Traits (Swift tools 6.1+) could work around it. They cannot: the issue's own text states Flutter would need to add trait support to its plugin tooling first, which it has not. +- **Three architectural alternatives to bring Purchase Connector onto SPM were evaluated and rejected for this release** (see `internal-docs/researches/R-001-spm-support.md` addendum): a second product in the same `Package.swift` (not viable β€” Flutter's tooling only links one product per plugin, no documented support for a second), an environment-variable-gated compile flag (technically usable but fragile β€” requires every consuming app to set an env var on every build/CI run with silent failure if forgotten), and splitting Purchase Connector into its own federated pub.dev package (architecturally sound, no hidden blocker, but a separate, larger initiative with its own versioning/release pipeline β€” a candidate future initiative, not part of this ticket). + +--- + +## Dependencies +```mermaid +flowchart LR + F060["F-060 Β· Swift Package Manager Support"]:::sdkCore + F054["F-054 Β· Purchase Connector: Build-Time Opt-in"]:::purchaseValidation + F060 -->|"adds a third, permanently-excluded iOS path to"| F054 + classDef sdkCore fill:#4C6EF5,color:#fff + classDef purchaseValidation fill:#F59F00,color:#fff +``` diff --git a/internal-docs/features/INDEX.md b/internal-docs/features/INDEX.md new file mode 100644 index 00000000..7ffa9557 --- /dev/null +++ b/internal-docs/features/INDEX.md @@ -0,0 +1,107 @@ +# AppsFlyer Flutter Plugin β€” Feature Catalog Index + +60 features across 6 categories. See `DIAGRAM.md` for runtime/init dependency diagrams and the full dependency table. + +--- + +## sdkCore + +SDK lifecycle, identity, privacy/consent, and low-level configuration. + +| ID | Name | Status | Platform | +|----|------|--------|----------| +| F-001 | SDK Initialization & Options Validation | active | both | +| F-002 | SDK Start (auto/manual + result handler) | active | both | +| F-003 | SDK/Plugin Version Retrieval | active | both | +| F-006 | Custom Host Configuration | active | both | +| F-007 | Device ID Collection Opt-out (IMEI/Android ID) | active | android | +| F-008 | Manual IMEI/Android ID Override | active | android | +| F-009 | Minimum Time Between Sessions | active | both | +| F-011 | TCF/DMA Automatic Consent Collection | active | both | +| F-012 | Manual GDPR/DMA Consent API (V1 + V2) | active | both | +| F-013 | User Anonymization (Opt-out logging) | active | both | +| F-015 | Customer User ID (CUID) | active | both | +| F-016 | Update vs. Fresh-Install Flag | active | android | +| F-017 | SDK Kill Switch (stop) | active | both | +| F-018 | Uninstall Measurement | active | both | +| F-019 | User Email Collection (with encryption) | active | both | +| F-020 | AppsFlyer UID Retrieval | active | both | +| F-021 | Delayed Session Start Pending CUID | active | android | +| F-034 | Advertising Identifier Collection Disable | active | both | +| F-046 | Disable Network Data Transfer | active | android | +| F-047 | AppSet ID Collection Opt-out (Android) | active | android | +| F-048 | Plugin Metadata Reporting to Native SDK | active | both | +| F-057 | ASA (Apple Search Ads) Collection Opt-out | active | ios | +| F-058 | ATT Authorization Wait Timeout (iOS) | active | ios | +| F-059 | Debug Logging Toggle | active | both | +| F-060 | Swift Package Manager (SPM) Support (Core, iOS) | active | ios | + +## eventsAndRevenue + +Reporting in-app events, ad revenue, and monetary context back to AppsFlyer. + +| ID | Name | Status | Platform | +|----|------|--------|----------| +| F-004 | In-App Event Logging | active | both | +| F-005 | Ad Revenue Logging | active | both | +| F-010 | Currency Code Setting | active | both | +| F-026 | Additional Custom Data | active | both | + +## purchaseValidation + +Server-side validation of purchases/subscriptions β€” legacy API and the Purchase Connector. + +| ID | Name | Status | Platform | +|----|------|--------|----------| +| F-023 | In-App Purchase Validation V1 (Android/iOS separate APIs) | deprecated | both | +| F-024 | In-App Purchase Validation V2 (cross-platform) | active | both | +| F-025 | iOS Receipt Validation Sandbox Toggle | active | ios | +| F-038 | Legacy Purchase-Validation Notification Callback | active | both | +| F-049 | Purchase Connector: Configuration & Lifecycle | active | both | +| F-050 | Purchase Connector: StoreKit Version Selection (iOS) | active | ios | +| F-051 | Purchase Connector: Android Validation Result Listeners | active | android | +| F-052 | Purchase Connector: iOS Combined Validation Callback | active | ios | +| F-053 | Purchase Connector: Google Play Purchase/Subscription Data Models | active | android | +| F-054 | Purchase Connector: Build-Time Opt-in (Android include/exclude variants) | active | both | +| F-055 | Missing-Configuration Guard for Purchase Connector | active | both | + +## deepLinking + +Resolving, forwarding, and delivering deep-link/attribution results across platform entry points. + +| ID | Name | Status | Platform | +|----|------|--------|----------| +| F-014 | Manual Deep-Link Re-trigger (performOnDeepLinking) | active | android | +| F-022 | Push Notification Deep-Link Path Config | active | both | +| F-031 | Push Notification Data Handling | active | both | +| F-032 | Facebook Deferred App Links | active | both | +| F-035 | Conversion Data Callback (GCD) | active | both | +| F-036 | App-Open Attribution Callback (OAOA) | active | both | +| F-037 | Unified Deep Linking (UDL) Callback & Models | active | both | +| F-039 | Native iOS Deep-Link Entry Points (URL scheme / Universal Links / Scenes) | active | ios | +| F-040 | Android New-Intent Deep-Link Forwarding | active | android | +| F-045 | Deep-Link URL Resolution Allow-list | active | both | + +## oneLinkAndGrowth + +OneLink-based invite/referral link generation and cross-app promotion. + +| ID | Name | Status | Platform | +|----|------|--------|----------| +| F-027 | User Invite Link Generation (OneLink) | active | both | +| F-028 | App Invite OneLink ID Configuration | active | both | +| F-029 | Cross-Promotion Impression/Click Tracking | active | both | +| F-030 | Custom/Branded OneLink Domains | active | both | +| F-056 | App Invite Link OneLink ID (init-time) | active | both | + +## platformIntegration + +Partner-ecosystem hooks and platform-specific attribution quirks. + +| ID | Name | Status | Platform | +|----|------|--------|----------| +| F-033 | SKAdNetwork Opt-out (iOS) | active | ios | +| F-041 | Current Device Language Override | active | ios | +| F-042 | Partner Postback Sharing Filter | active | both | +| F-043 | Out-of-Store Install Source (Android) | active | android | +| F-044 | Partner-Specific Data | active | both | diff --git a/internal-docs/features/TEMPLATE.md b/internal-docs/features/TEMPLATE.md new file mode 100644 index 00000000..c90ff645 --- /dev/null +++ b/internal-docs/features/TEMPLATE.md @@ -0,0 +1,59 @@ +--- +id: F-NNN +name: Feature Name +type: [category] +platform: [platform] +status: active / planned / deprecated +last_verified: YYYY-MM-DD +depends_on: [] +--- + +## Business Purpose +Why this feature exists. What the user or product loses if it is removed. + +--- + +## Trigger +When this feature runs. What condition activates it. + +--- + +## Call Chain +``` +EntryPoint::method() + β†’ NextLayer::method() [file] + β†’ FinalLogic::method() [file] +``` + +--- + +## Files +| File | Role | +|------|------| + +--- + +## Input / Output +| | | +|--|--| +| **Input** | What comes in | +| **Output** | What goes out | + +--- + +## Tests +`path/to/test_file` β€” what the tests cover. + +--- + +## Known Limitations +- Limitation β€” why it exists, what the risk is + +--- + +## Dependencies +```mermaid +flowchart LR + FXXX["F-XXX Β· This Feature"]:::typeA -->|"relationship"| FYYY["F-YYY Β· Other Feature"]:::typeB + [classDef blocks β€” one per approved category] +``` diff --git a/internal-docs/prds/spm-support.md b/internal-docs/prds/spm-support.md new file mode 100644 index 00000000..3cd10f0d --- /dev/null +++ b/internal-docs/prds/spm-support.md @@ -0,0 +1,71 @@ +--- +ticket: DELIVERY-125462 +priority: P1 +target: v6.18.0, end of July 2026 +--- + +# PRD: Swift Package Manager (SPM) Support + +## Problem + +The plugin's iOS integration ships only via CocoaPods (`ios/appsflyer_sdk.podspec`). Two industry shifts make this untenable on the current timeline: + +1. Flutter 3.44+ makes Swift Package Manager the default iOS integration mechanism. Plugins without an SPM manifest already surface a build warning in consuming apps today. +2. CocoaPods trunk (the `pod repo push` publishing path) goes **read-only on December 2, 2026**. Once that happens, the plugin cannot ship *new* CocoaPods releases at all β€” the build warning becomes a hard build error for any app that hasn't migrated, and we lose the ability to patch the CocoaPods distribution. + +Competing attribution SDKs (Adjust, Singular) already support SPM, so apps that need SPM today are choosing those SDKs over ours. The community has raised this twice (tracking issue #364, draft PR #370) and both attempts stalled on the same blocker: the `PurchaseConnector` subspec has no clean SPM path because it depends on an upstream Flutter engine limitation (flutter/flutter#161182) that is outside this plugin's control. + +## Goal + +Ship a `Package.swift` manifest so apps can integrate the plugin's Core (default) functionality via SPM, while `PurchaseConnector` remains CocoaPods-only until upstream Flutter resolves flutter/flutter#161182. Existing CocoaPods consumers must see zero behavior change. + +Success: an app can add the plugin via SPM and get full attribution/deep-linking functionality (everything except Purchase Connector) with no CocoaPods dependency, by end of July 2026, in v6.18.0. + +## Non-goals + +- Making `PurchaseConnector` available via SPM β€” explicitly blocked on flutter/flutter#161182; out of scope until that upstream issue is resolved. +- Dropping or deprecating CocoaPods support β€” CocoaPods remains fully supported in this release. +- Migrating the Android side of the plugin (SPM is iOS/Apple-platform-only; no Android equivalent exists). +- Evaluating Swift Package Manager Traits (Swift tools 6.1+) as a mechanism to ship `PurchaseConnector` conditionally via SPM β€” flagged as a candidate for Bob to research, but committing to it is out of scope for this PRD until Bob confirms it's viable and doesn't just relocate the same upstream Flutter blocker. + +## User/customer impact + +- **Apps not using Purchase Connector**: can adopt SPM immediately, removing their CocoaPods dependency and the build warning; avoids a hard build break after Dec 2, 2026. +- **Apps using Purchase Connector**: must stay on CocoaPods (full install) until the upstream blocker resolves. They are not broken by this change, but they don't get the SPM option yet β€” this is a real, currently-unavoidable gap that needs to be communicated clearly in docs/release notes so these teams aren't surprised post-Dec-2026. +- **Existing CocoaPods consumers (any config)**: no behavior change β€” this PRD requires full backward compatibility as an explicit requirement, not an assumption. + +## Requirements + +1. Add a `Package.swift` manifest exposing the Core integration as an SPM product, building on the approach already prototyped in draft PRs #455 and #454. +2. `PurchaseConnector` is NOT exposed via SPM in this release; it remains a CocoaPods-only subspec, gated the same way `appsflyer.enable_purchase_connector` / `$AppsFlyerPurchaseConnector` already gate it today (see F-054). +3. `ios/appsflyer_sdk.podspec` continues to work unmodified in behavior for existing CocoaPods consumers β€” both the Core-only and Core+PurchaseConnector configurations. +4. Both integration paths must be verified before release: + - SPM-only (Core, no PurchaseConnector) + - CocoaPods, Core only + - CocoaPods, Core + PurchaseConnector + - (Explicitly NOT required: SPM + PurchaseConnector β€” not supported this release) +5. `CHANGELOG.md` and plugin release notes document: SPM support added, PurchaseConnector's CocoaPods-only status and why, and a pointer to flutter/flutter#161182 for apps tracking when Purchase Connector SPM support might land. +6. Ship as part of the current SDK 6 line, v6.18.0. + +## Acceptance criteria + +- [ ] A fresh Flutter app added via SPM (no `Podfile`) builds successfully on iOS and can call Core attribution APIs (init, start, event logging) end to end. +- [ ] A fresh Flutter app using CocoaPods with `PurchaseConnector` disabled builds and behaves identically to pre-change behavior. +- [ ] A fresh Flutter app using CocoaPods with `PurchaseConnector` enabled builds and behaves identically to pre-change behavior. +- [ ] Attempting to reference Purchase Connector APIs from an SPM-only integration fails at build/compile time with a clear signal (not a silent runtime no-op) β€” exact mechanism to be defined by Dave in tech design. +- [ ] `CHANGELOG.md` entry and release notes are published alongside v6.18.0 describing the SPM addition and the PurchaseConnector CocoaPods-only limitation. +- [ ] No existing `example/` app (CocoaPods-based) requires any change to keep building. + +## Risks + +- **Release risk**: this touches the iOS distribution mechanism for every consumer of the plugin, including all existing CocoaPods apps. A `Package.swift` misconfiguration or podspec regression could break builds plugin-wide. Requires explicit verification of all three supported build paths (Requirement 4) before shipping, not just the new SPM path. +- **Confusing failure mode risk**: if referencing Purchase Connector from an SPM-only integration fails silently or with an unclear Swift compiler error, it repeats the exact "confusing MissingPluginException" failure pattern already documented as a known limitation of the existing CocoaPods opt-in gate (F-054). Dave's tech design must address this explicitly. +- **Deadline risk**: CocoaPods trunk goes read-only Dec 2, 2026, well after this July 2026 ship date β€” no schedule risk from that deadline itself, but it does mean this is the last comfortable window to ship before urgency increases. +- **Scope creep risk**: SPM Package Traits (surfaced during research) could look like a tempting way to "solve" the PurchaseConnector gap now. Bob must confirm whether it actually changes anything about the flutter/flutter#161182 blocker before any decision to expand scope β€” the default assumption per ticket is that PurchaseConnector stays CocoaPods-only this release regardless of what traits offer. + +## Open questions + +- ~~Does flutter/flutter#161182 block *any* SPM path for PurchaseConnector, or does SPM Package Traits (Swift tools 6.1+) offer a way around it?~~ **Resolved (R-001):** flutter/flutter#161182 is still open and is about Flutter's own plugin build tooling lacking conditional-compilation support β€” not something SwiftPM Traits can fix from our side, since Flutter doesn't route plugin builds through traits today. PurchaseConnector stays CocoaPods-only this release, as originally scoped; traits are not a viable shortcut. +- ~~What is the minimum Xcode / Swift tools version the target Package.swift manifest requires, and is it compatible with the Flutter versions this plugin currently supports?~~ **Resolved (R-001):** `AppsFlyerFramework`'s own SPM package (dependency) requires Swift tools 5.3; draft PRs use tools-version 5.9 (Xcode 15.0+ minimum) for this plugin's own manifest. No conflict with `pubspec.yaml`'s Dart SDK/Flutter constraints β€” SPM eligibility is gated by the consuming app's Flutter tool version, not this package's declared environment. +- What exact compile-time signal should apps get if they reference Purchase Connector APIs without CocoaPods? (compiler error vs. missing symbol vs. something else) β€” **Dave to resolve in tech design.** (R-001 notes this should surface as a build/link error, not a silent runtime no-op β€” an improvement over F-054's existing CocoaPods failure mode β€” but Dave must confirm this holds for the SPM path specifically.) +- Do draft PRs #455/#454 already answer the Package.swift structure question, or do they need re-validation against the current plugin structure? β€” **Partially resolved (R-001):** #454 is the recommended starting point (move-based layout, already isolates PurchaseConnector correctly) but its dependency declaration is wrong (`AppsFlyerLib`, not `AppsFlyerLib-Static`) and neither draft PR completed real CI/device-build verification β€” **Dave to re-validate and correct in tech design**, and confirm the exact required `Package.swift` path convention against Flutter's official plugin-author SPM guide. diff --git a/internal-docs/researches/R-001-spm-support.md b/internal-docs/researches/R-001-spm-support.md new file mode 100644 index 00000000..4b14358f --- /dev/null +++ b/internal-docs/researches/R-001-spm-support.md @@ -0,0 +1,91 @@ +--- +id: R-001 +title: Swift Package Manager (SPM) support β€” feasibility, PurchaseConnector blocker, and prior art +versions: "Flutter 3.24 (experimental) – 3.44+ (default); Swift tools 5.3 – 5.9; Xcode 12+ (Package.swift baseline), Xcode 15+ (this plugin's actual manifest)" +status: complete +date: 2026-07-19 +affects-features: [F-054] +related-issue-cases: [] +--- + +## Summary + +Researched for DELIVERY-125462 / PRD `docs/prds/spm-support.md`. No prior research or issue-case docs existed on this topic (`docs/researches/` and `docs/issue-cases/` are both empty in this repo). Checked GitHub directly (issues/PRs on this plugin's repo, flutter/flutter, and AppsFlyerSDK/AppsFlyerFramework) rather than relying on secondhand summaries. + +Key finding: **Swift Package Manager Traits do NOT unblock PurchaseConnector.** flutter/flutter#161182 β€” the exact issue the ticket cites β€” is literally titled "[SwiftPM] Support conditional compilation in plugins" and is still **OPEN**, unassigned, P3. It states plainly: "Swift Package Manager does not support conditional compilation," and lists two possible fixes, neither shipped: (1) a documented hacky workaround, or (2) "Update Flutter to support Swift package traits **if/when that lands**." Traits are a SwiftPM-language feature (SE-0450, Swift tools 6.1+) β€” the blocker is that **Flutter's own plugin build tooling** has no support for conditional compilation of plugin code, with or without traits underneath. Until Flutter's tooling adds that support, PurchaseConnector cannot be conditionally included via SPM regardless of what SwiftPM itself offers. This confirms the PRD's non-goal was correctly scoped: don't chase traits for this release. + +Second finding: two real, unmerged draft PRs already exist on this exact repo with working (self-reported) Core-only SPM implementations β€” #454 and #455 β€” and they diverge in approach. #454 is the more directly relevant prior art (explicitly designed to avoid the PurchaseConnector blocker); its dependency pin has a naming inaccuracy that Dave should not copy verbatim (see below). + +## API / Platform Details + +**flutter/flutter#161182** β€” "[SwiftPM] Support conditional compilation in plugins," opened by `loic-sharma` (Flutter/iOS team), state: **OPEN**, labels `c: new feature, P3, platform-ios, platform-macos, team-ios, triaged-ios`, no assignee. +- Root cause: CocoaPods plugins can gate optional features behind compile flags set in an app's Podfile/gradle-equivalent (this plugin already does exactly that for PurchaseConnector via `$AppsFlyerPurchaseConnector`, see F-054). SwiftPM has no equivalent mechanism *as surfaced through Flutter's plugin system* today. +- Named affected packages besides us: `just_audio` (optional microphone feature), `permission_handler`. +- Proposed fixes, both unshipped: a documented hacky workaround (external gist), or adopting Swift Package Traits (SE-0450) once Flutter's tooling supports them. + +**flutter/flutter tracking issue #364** on our own repo (`AppsFlyerSDK/appsflyer-flutter-plugin`) β€” filed by the Flutter team's automated outreach (`loic-sharma`), **state: CLOSED** (labels: `enhancement, stale`), assigned to an AppsFlyer employee (Dani Koza). This is not really "community-proposed" in the grassroots sense β€” it's Flutter's own campaign to get plugin authors to migrate, sent directly to us, that went stale. + +**Draft PR #370** β€” `AppsFlyerSDK/appsflyer-flutter-plugin`, state DRAFT, external contributor, touches the podspec only ("Updated podspec"), closes #364. No Package.swift authored. Confirms the ticket's claim that this attempt stalled without a working manifest. + +**Draft PR #454** β€” "feat(ios): add Swift Package Manager support for Core," state OPEN, external contributor (`nurlangarash`), 52 changed lines across 8 files. This is the most directly relevant prior art: +- Moves `ios/Classes/*.m` β†’ `ios/appsflyer_sdk/Sources/appsflyer_sdk/` (impl) and `.../include/appsflyer_sdk/` (public headers) via `git mv` β€” required because SPM expects a specific source-tree layout, unlike CocoaPods' `source_files` glob. +- Adds `ios/appsflyer_sdk/Package.swift`, `swift-tools-version: 5.9`, `platforms: [.iOS(.v12)]`. +- PR description claims it depends on "AppsFlyerFramework-Static SPM package (product `AppsFlyerLib-Static`, pinned `6.18.0`)" β€” **this is inaccurate**. I fetched `AppsFlyerFramework`'s actual `Package.swift` at tag `6.18.0` directly: the product name is `AppsFlyerLib` (not `AppsFlyerLib-Static`), `swift-tools-version:5.3`, and it resolves to a binary xcframework at `https://github.com/AppsFlyerSDK/AppsFlyerFramework/releases/download/6.18.0/AppsFlyerLib-Static-SPM.xcframework.zip` (the *file* is named `-Static-SPM`, the *product* is not). Dave should depend on product `AppsFlyerLib`, not `AppsFlyerLib-Static`, and pin `from: "6.18.0"` to match the podspec's existing `ss.ios.dependency 'AppsFlyerFramework','6.18.0'` β€” do not blindly copy the PR's dependency declaration. +- Leaves `PurchaseConnector/` untouched on CocoaPods; the existing `#ifdef ENABLE_PURCHASE_CONNECTOR` guard (see F-054) already compiles it out of any target that doesn't define the macro, which is exactly the mechanism the SPM Core target relies on implicitly (SPM target simply never defines the macro or includes PurchaseConnector sources). +- Explicitly flagged by its own author as unverified: "⚠️ Please run CI / a device build before merging... I could not run a full iOS build here." + +**Draft PR #455** β€” "feat(ios): add Swift Package Manager support," state OPEN, different external contributor (`TeddyYeung`), 1432 changed lines. Broader/older attempt: keeps `ios/Classes/` in place and adds a separate `ios/appsflyer_sdk/Sources/appsflyer_sdk/` tree (mirrors rather than moves), declares `AppsFlyerFramework` as a binary dependency directly rather than depending on its published SPM package. Author reports both SPM and CocoaPods builds succeeded locally with Flutter 3.35.7. No device-level test evidence beyond that. Larger diff, more duplication between CocoaPods and SPM source trees than #454's move-based approach. + +**AppsFlyerFramework SPM package** (`AppsFlyerSDK/AppsFlyerFramework`): confirmed via GitHub API that both the `6.18.0` and `7.0.0` tags carry a working `Package.swift` (binary xcframework target, tools-version 5.3). The plugin's current podspec already pins native SDK `6.18.0` β€” that tag's SPM package is confirmed present and resolvable, so no native-SDK version bump is required to add SPM support in this release. + +## Behavior by Version + +| Version | Behavior | Notes | +|---------|----------|-------| +| Flutter < 3.24 | No SPM awareness; CocoaPods only | No change needed β€” these apps are unaffected either way | +| Flutter 3.24 – 3.43 | SPM available behind `flutter config --enable-swift-package-manager` (experimental, opt-in) | Apps must explicitly opt in to hit our new SPM path | +| Flutter 3.44+ | SPM is the **default** iOS integration; plugins without a manifest emit the "does not support Swift Package Manager" build warning | This is the driver for the ticket's urgency | +| Swift tools 5.3 | Minimum declared by `AppsFlyerFramework`'s own Package.swift (both 6.18.0 and 7.0.0 tags) | Not a constraint we control but must stay compatible with | +| Swift tools 5.9 (used by draft PR #454) | Requires Xcode 15.0+ to resolve/build | Xcode 15 shipped Sept 2023 β€” not a meaningful constraint for apps building in July 2026 | +| CocoaPods trunk | Read-only from **Dec 2, 2026** | After this date we lose the ability to publish *new* CocoaPods releases β€” not a factor for this July 2026 ship date, but the reason this can't slip past that window | + +## SDK/Service Impact + +- **F-054 (Purchase Connector: Build-Time Opt-in)** is directly relevant and should be updated once implementation lands: its "Files" and "Call Chain" sections describe the CocoaPods-only `#ifdef ENABLE_PURCHASE_CONNECTOR` gate. That gate is the same mechanism the SPM Core target relies on (by omission β€” the SPM target never defines the macro or references PurchaseConnector sources at all). Recommend Dave add a note there once the SPM manifest exists, since it becomes a *third* code path relying on the same guard, not just Android Gradle + iOS CocoaPods. +- Dave's tech design should decide, and document, what happens if an app tries to reference Purchase Connector Dart APIs while integrated via SPM. Given the ObjC `#ifdef` guard is compiled out entirely, the native symbol won't exist β€” this should surface as a build/link error in the consuming app's Xcode build (undefined symbol / missing plugin registration), not a silent runtime no-op. This is an *improvement* over the existing CocoaPods failure mode (F-054's Known Limitations documents that CocoaPods opt-out currently fails silently at runtime with a generic Flutter `MissingPluginException`). Dave should confirm this build-time-vs-runtime distinction holds for the SPM path specifically before claiming it in the tech design. +- No changes are needed to `pubspec.yaml`'s Dart SDK/Flutter environment constraints (`>=2.17.0 <4.0.0` / `>=1.10.0`) β€” SPM eligibility is gated by the Flutter *tool* version an app builds with, not by this package's declared Dart SDK constraint. Existing CocoaPods consumers on old Flutter versions are entirely unaffected. +- Recommend Dave use PR #454's move-based file layout (not #455's mirror-based layout) as the starting structure β€” less duplication, and its author already anticipated the PurchaseConnector guard correctly β€” but correct the dependency declaration to product `AppsFlyerLib` (not `AppsFlyerLib-Static`) pinned `from: "6.18.0"`, and complete the CI/device build verification neither draft PR finished. +- **Compliance/privacy β€” no impact, verified directly.** Downloaded and inspected both native-SDK distribution artifacts for tag `6.18.0` rather than assuming: the CocoaPods pod (`AppsFlyerFramework.podspec`) sources from `AppsFlyerLib-Binaries.zip` and declares the Apple privacy manifest via `resource_bundles = {'AppsFlyerLib_Privacy' => [...PrivacyInfo.xcprivacy]}`. The SPM binary target sources from a *different* zip (`AppsFlyerLib-Static-SPM.xcframework.zip`) β€” I downloaded it and confirmed `PrivacyInfo.xcprivacy` is embedded directly inside each per-platform slice of the xcframework itself (`AppsFlyerLib.xcframework/ios-arm64/AppsFlyerLib.framework/PrivacyInfo.xcprivacy`, and five other platform slices). Same privacy manifest content, different packaging convention (CocoaPods resource bundle vs. SPM's expected in-framework embedding) β€” this is the standard, Apple-documented way privacy manifests differ by distribution mechanism, not a gap. No new data collection, consent, or tracking-disclosure surface is introduced by adding the SPM path; it ships the exact same native binary's declared privacy behavior, just packaged per SPM's own convention. Out of scope for further compliance review. +- **Platform/integration risk β€” no App Store precedent found.** I did not find any documented Apple App Store review policy that distinguishes between CocoaPods-distributed and SPM-distributed dependencies β€” Apple's review process operates on the built app binary and its declared entitlements/privacy manifests, not on which dependency manager assembled it. No rejection precedent tied to distribution mechanism itself is known. Stating this explicitly rather than leaving it silent: this is not a risk vector for this change. + +## Open Questions + +1. Neither draft PR ran a full CI pipeline or device build (both explicitly flag simulator/local-only or "please verify before merging") β€” Dave's tech design must include real verification of all four build-path combinations from the PRD's acceptance criteria, not reuse the drafts' informal testing claims. +2. Should the plugin's `Package.swift` live at `ios/appsflyer_sdk/Package.swift` (both drafts' choice, required by SPM's convention of the manifest sitting at the package root alongside `Sources/`) β€” confirm this is compatible with how `flutter pub` locates iOS plugin folders; the ticket and both PRs assume yes but I did not find an authoritative Flutter doc confirming the exact required path for a **plugin's nested** SPM package (vs. a repo that is only an SPM package). Dave should verify against the official Flutter SPM plugin-author guide linked in PR #455 before finalizing the path. +3. Whether the CocoaPods podspec needs any accompanying change to declare compatibility/coexistence with the new SPM manifest (some Flutter plugin migrations add a marker so `flutter` tooling detects SPM availability) β€” not established by either draft PR; Dave to confirm against the Flutter plugin-author migration guide. + +## Addendum β€” can PurchaseConnector be included in SPM at all, via a different architecture? + +Follow-up investigation: R-001's original conclusion (PurchaseConnector stays CocoaPods-only) was specifically about the "single target, opt-in compile flag" pattern (matching `just_audio`'s approach, the pattern flutter/flutter#161182 is literally about). Two structurally different architectures were checked concretely rather than assumed away: + +**1. Multi-product single package β€” not viable via supported Flutter tooling.** A `Package.swift` *can* technically declare two separate library products (Core + PurchaseConnector as distinct targets) β€” that's plain SPM, no traits needed. But Flutter's own plugin-authoring model, per the official guide, links exactly **one** product per plugin, matching the plugin's registered name (`plugin_name` β†’ library `plugin-name`) β€” there is no documented mechanism for a second, app-opt-in product, and the guide does not describe `FlutterGeneratedPluginSwiftPackage` (the tool-managed aggregator package Flutter generates from `pubspec.yaml`) as supporting manual edits or additional per-plugin products. Any hand-added Xcode-level dependency edge to a non-default product would be at risk of being wiped by Flutter's own regeneration on `flutter pub get`/`flutter build` β€” this is exactly the class of problem flutter/flutter#161182 is asking Flutter to solve, and it isn't solved yet. **Not recommended**: relies on undocumented, unsupported tool behavior. + +**2. Documented hacky workaround (env-var-gated compile flag) β€” technically usable, not recommended for a published package.** Flutter's own issue links to https://github.com/loic-sharma/swiftpm_conditional_compilation, which works by reading `ProcessInfo.processInfo.environment` **inside `Package.swift`'s manifest evaluation** and conditionally adding a `SwiftSetting.define(...)` flag if an environment variable is set to `"1"` at the time the consuming app invokes `flutter run`/`flutter build`. This is something a plugin author *can* write into a public package's `Package.swift` β€” nothing blocks it technically. But the consuming app must (a) set that env var on every single build/run invocation (local dev *and* CI/release pipelines) and (b) run `flutter clean` every time the value changes, since SPM does not auto-invalidate the build when the env var flips β€” the workaround's own README documents this as a required manual step, not automatic. A missed env var in a release CI pipeline would silently disable Purchase Connector with zero build warning. This is a materially worse and more fragile experience than today's one-time `$AppsFlyerPurchaseConnector = true` Podfile flag or Android's `gradle.properties` flag (set once, persists across builds). **Not recommended for a published pub.dev plugin**: pushes a fragile, easy-to-silently-break requirement onto every consumer's build pipeline. + +**3. Federated package split β€” architecturally sound, no blocker found, but out of scope for this ticket.** Splitting `PurchaseConnector` into its own independent Flutter package (e.g. `appsflyer_purchase_connector`) with its own `pubspec.yaml`, podspec, `Package.swift`, and Android `build.gradle` sidesteps flutter/flutter#161182 entirely β€” "is this package a `pubspec.yaml` dependency or not" is not conditional compilation, it's the normal dependency-resolution mechanism Flutter has always fully supported for both CocoaPods and SPM. This is the same pattern Firebase (`firebase_core` + `cloud_firestore`, etc.) and federated plugins (platform-interface splits) already use in production at scale β€” no hidden blocker found. On the Dart side, `package:appsflyer_sdk/purchase_connector.dart` could remain a working import path via an `export 'package:appsflyer_purchase_connector/purchase_connector.dart';` re-export shim, so existing Dart-level imports would not need to change. **However**, this does NOT make it a small change: it requires the new package to carry its own `pluginClass`/native plugin registration (currently `PurchaseConnector` is a CocoaPods *subspec* of the same plugin, not an independently-registered Flutter plugin at all), its own independent versioning and release process through the six-stage RC pipeline, a migration/deprecation path for existing consumers' native build files (`Podfile`/`gradle.properties` flags would change meaning or need replacing), and coordination with whatever timeline is acceptable for a breaking-ish native architecture change. This is a real, viable option β€” but it is a separate, larger initiative, not something that fits inside DELIVERY-125462's July 2026 / v6.18.0 window alongside Core SPM support. + +**Recommendation**: none of the three options make "PurchaseConnector via SPM, this release" viable without either relying on unsupported Flutter tooling behavior (option 1), pushing real production fragility onto every consumer (option 2), or taking on a materially larger, independently-scoped migration (option 3). The current PRD's non-goal (PurchaseConnector stays CocoaPods-only, revisit when flutter/flutter#161182 resolves) remains the soundest call for this ticket. Option 3 is worth raising as a candidate follow-up initiative if PurchaseConnector-via-SPM becomes a hard requirement before flutter/flutter#161182 resolves β€” but that is a scope/roadmap decision, not a technical necessity for DELIVERY-125462. + +## References + +- flutter/flutter#161182 β€” https://github.com/flutter/flutter/issues/161182 (open, unresolved, primary blocker) +- AppsFlyerSDK/appsflyer-flutter-plugin#364 β€” https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/issues/364 (closed, stale) +- AppsFlyerSDK/appsflyer-flutter-plugin#370 β€” https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/pull/370 (draft, podspec-only, no manifest) +- AppsFlyerSDK/appsflyer-flutter-plugin#454 β€” https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/pull/454 (draft, Core-only, move-based, recommended starting point) +- AppsFlyerSDK/appsflyer-flutter-plugin#455 β€” https://github.com/AppsFlyerSDK/appsflyer-flutter-plugin/pull/455 (draft, broader/older, mirror-based) +- AppsFlyerSDK/AppsFlyerFramework `Package.swift` at tags `6.18.0` and `7.0.0` (fetched directly via GitHub API) +- loic-sharma/swiftpm_conditional_compilation β€” https://github.com/loic-sharma/swiftpm_conditional_compilation (documented hacky workaround, env-var + manual `flutter clean` gated, not recommended for a published plugin) +- Flutter SPM plugin-author guide β€” https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors (confirms one product per plugin is the only documented/supported pattern) +- Flutter SPM guide for plugin authors β€” https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors +- Swift Evolution SE-0450 (Package Manager Traits) β€” https://github.com/swiftlang/swift-evolution/blob/main/proposals/0450-swiftpm-package-traits.md (referenced from within flutter/flutter#161182 itself) +- Official Swift docs on Package Traits β€” https://docs.swift.org/swiftpm/documentation/packagemanagerdocs/packagetraits/ (used to independently verify traits syntax; confirmed accurate but confirmed **not currently applicable** because Flutter's tooling, not SwiftPM, is the blocker) diff --git a/internal-docs/tech-designs/spm-support.md b/internal-docs/tech-designs/spm-support.md new file mode 100644 index 00000000..5766fa06 --- /dev/null +++ b/internal-docs/tech-designs/spm-support.md @@ -0,0 +1,138 @@ +--- +ticket: DELIVERY-125462 +prd: internal-docs/prds/spm-support.md +research: internal-docs/researches/R-001-spm-support.md +planned_feature_doc: F-060 β€” doc to be written after development is complete +--- + +# Tech Design: Swift Package Manager (SPM) Support + +## Context table + +| Type | ID | Name | +|------|----|------| +| Issue case | none | `docs/issue-cases/` does not exist in this repo yet β€” no hot-zone history to check | +| Feature doc | F-054 | Purchase Connector: Build-Time Opt-in β€” directly extended by this design | + +## Approach + +Move (not mirror) `ios/Classes/` into an SPM-compatible tree shared by both CocoaPods and SPM, following the official Flutter plugin-author SPM migration guide exactly (verified directly at https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-plugin-authors β€” not just copied from the draft PRs): + +``` +ios/ +β”œβ”€β”€ appsflyer_sdk/ # NEW β€” SPM package root +β”‚ β”œβ”€β”€ Package.swift # NEW β€” SPM manifest +β”‚ └── Sources/appsflyer_sdk/ +β”‚ β”œβ”€β”€ AppsflyerSdkPlugin.m # moved from ios/Classes/ +β”‚ β”œβ”€β”€ AppsFlyerAttribution.m # moved +β”‚ β”œβ”€β”€ AppsFlyerStreamHandler.m # moved +β”‚ └── include/appsflyer_sdk/ +β”‚ β”œβ”€β”€ AppsflyerSdkPlugin.h # moved (public header) +β”‚ β”œβ”€β”€ AppsFlyerAttribution.h +β”‚ β”œβ”€β”€ AppsFlyerStreamHandler.h +β”‚ └── FlutterAppDelegate+AppsFlyerStreamHandler.h +β”œβ”€β”€ appsflyer_sdk.podspec # UPDATED β€” source_files/public_header_files repointed +β”œβ”€β”€ .gitignore # UPDATED β€” add .build/ and .swiftpm/ +└── PurchaseConnector/ # UNCHANGED β€” stays CocoaPods-only, untouched +``` + +`ios/.gitignore` must add `.build/` and `.swiftpm/` per the official migration guide's checklist (step 10) β€” these are local SPM resolution/build artifacts that must not be committed, same rationale as `.dart_tool/`/`build/` already being ignored at the Dart level. + +This matches draft PR #454's structure (not #455's mirror-based duplication), which the official guide independently confirms is the correct approach: the guide's own migration checklist deletes `ios/Classes/` entirely after moving β€” there is exactly one copy of Core's source, referenced by both the podspec (CocoaPods path) and `Package.swift` (SPM path). `pubspec.yaml` requires **no changes** β€” `pluginClass: AppsflyerSdkPlugin` continues to resolve via `` in the new location, per the guide. + +### `ios/appsflyer_sdk/Package.swift` + +```swift +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "appsflyer_sdk", + platforms: [.iOS("12.0")], + products: [ + .library(name: "appsflyer-sdk", targets: ["appsflyer_sdk"]) + ], + dependencies: [ + .package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework.git", .exact("6.18.0")) + ], + targets: [ + .target( + name: "appsflyer_sdk", + dependencies: [ + .product(name: "AppsFlyerLib", package: "AppsFlyerFramework") + ], + cSettings: [ + .headerSearchPath("include/appsflyer_sdk") + ] + ) + ] +) +``` + +**Correction to draft PR #454**: its PR description names the dependency product `AppsFlyerLib-Static`. I fetched `AppsFlyerFramework`'s actual `Package.swift` at tag `6.18.0` directly via GitHub API β€” the declared product name is `AppsFlyerLib`, not `AppsFlyerLib-Static` (that string only appears in the *binary artifact's zip filename*, not the SPM product). Using the wrong product name would fail dependency resolution outright. Pin `.exact("6.18.0")` to match the podspec's existing `ss.ios.dependency 'AppsFlyerFramework','6.18.0'` exactly β€” no native SDK version bump required (R-001 confirmed the 6.18.0 tag's own Package.swift resolves and is valid). + +**Correction (post-review)**: the original design used `from: "6.18.0"`, a semver-range requirement (`6.18.0..<7.0.0`) rather than an exact pin. This was caught during PR review β€” the CI E2E run cited in the PR's test plan actually resolved and ran against `AppsFlyerFramework` **6.18.1**, not 6.18.0, exposing a real asymmetry: CocoaPods consumers get exactly 6.18.0, SPM consumers could silently float onto any untested patch/minor release below 7.0.0. Changed to `.exact("6.18.0")` so both distribution paths pin identically. Re-verified via `swift package describe`: `Requirement: Exact: 6.18.0`. + +### `ios/appsflyer_sdk.podspec` β€” path updates only, no marker needed + +Per the official guide, **no special marker or flag is needed in the podspec to declare SPM availability** β€” the Flutter tool detects SPM support purely by the presence of `ios/appsflyer_sdk/Package.swift` at the conventional path. The podspec only needs its `Core` subspec's paths repointed to the moved files: + +```ruby +s.subspec 'Core' do |ss| + ss.source_files = 'appsflyer_sdk/Sources/appsflyer_sdk/**/*.m' + ss.public_header_files = 'appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/*.h' + ss.dependency 'Flutter' + ss.ios.dependency 'AppsFlyerFramework','6.18.0' +end +``` + +`PurchaseConnector` subspec is untouched β€” its `source_files = 'PurchaseConnector/**/*'` still points at the existing, unmoved directory. + +## PurchaseConnector isolation β€” corrected failure-mode analysis + +R-001 hypothesized that referencing Purchase Connector APIs from an SPM-only integration would fail as a **build/link error** (an improvement over F-054's documented silent-runtime `MissingPluginException`). Tracing the actual mechanism, **this hypothesis is wrong** β€” the real behavior is identical to today's CocoaPods opt-out path, not better: + +- `ios/PurchaseConnector/` is never added to the SPM target's `Sources/` tree β€” it's a completely separate directory the `Package.swift` above never references. +- The existing `#ifdef ENABLE_PURCHASE_CONNECTOR` guard in `AppsflyerSdkPlugin.m` (moved, unmodified) depends on the `ENABLE_PURCHASE_CONNECTOR=1` preprocessor macro, which today is set only via the podspec's `pod_target_xcconfig` on the `PurchaseConnector` subspec (a CocoaPods-only mechanism β€” SPM has no equivalent `xcconfig` macro injection path in this design). +- Therefore in an SPM-only build, that macro is simply never defined β€” the guard resolves to false exactly as it does today for a CocoaPods app that didn't opt in. +- Net effect: an app integrated via SPM that calls a Purchase Connector Dart API gets the **same outcome as today's undocumented CocoaPods opt-out** β€” the `af-purchase-connector` MethodChannel has no registered handler, and Flutter raises its own `MissingPluginException` at runtime, not at build time. This is not an improvement; it is the same known limitation F-054 already documents, now reachable via a third path. + +**Decision**: accept this as the same known-limitation behavior, not attempt to introduce a build-time guard for this release. Rationale: making PurchaseConnector fail differently (e.g., a Swift `#error` directive) would require adding conditional logic that reads consuming-app config *inside* the Package.swift/SPM target β€” which is precisely what flutter/flutter#161182 says SPM cannot yet do for Flutter plugins. Manufacturing a compile-time signal is out of scope until that's resolved; this PRD's non-goal (no SPM Purchase Connector this release) already excludes it. Flag for Phase 3: F-054's Known Limitations section needs a new bullet noting this is now reachable via SPM too, not just the two existing CocoaPods/Gradle paths β€” and R-001's speculative "improvement" claim should not be repeated in the final feature doc. + +## Migration & rollout risk + +- **No opt-in required, no behavior change for existing users.** CocoaPods apps continue to resolve via the podspec exactly as before β€” same subspecs, same dependency versions, only the on-disk source location changed (transparent to consumers, who never reference `ios/Classes/` paths directly). +- **Public API surface**: unchanged. No new Dart methods, no MethodChannel changes. This is purely an iOS build/distribution-mechanism addition. +- **Rollback plan**: if a regression surfaces post-release, revert the file move + podspec path change + delete `Package.swift`; CocoaPods consumers are unaffected either way since the podspec keeps working throughout development (verified per-build-path below, not assumed). +- **Big-bang vs gradual**: this ships in v6.18.0 as a single release; SPM adoption itself is gradual and consumer-controlled (Flutter's own `--enable-swift-package-manager` flag / 3.44+ default) β€” we're not forcing anyone onto SPM, only making it available. + +## Concurrency & Thread Safety + +**N/A for this change.** No runtime or concurrent code path is touched β€” the `.m`/`.h` files are relocated verbatim (`git mv`, no content changes to the moved implementation), and the only new artifacts (`Package.swift`, podspec path updates, `.gitignore`) are build-time manifests with no executable logic, threading, or callback/completion-handler code of their own. + +## Test Coverage + +**No automated unit test is added.** This falls in the same category as F-054 (Purchase Connector: Build-Time Opt-in), which is explicitly documented as untested at the unit level because "this is a Gradle/CocoaPods build-configuration concern with no Dart or native unit test coverage; verifying it requires two full builds (opted-in vs. opted-out) rather than a unit test." The same reasoning applies here: there is no Dart or native runtime logic change to unit-test β€” only source-tree layout and build manifests. The Verification plan below (4 real build-path checks) is the equivalent verification for this category of change, not a substitute being skipped. + +## Verification plan (mandatory β€” neither draft PR completed this) + +Both #454 and #455 self-report only local/simulator builds and explicitly ask reviewers to verify before merging. This design requires actually running all three supported build paths from the PRD's acceptance criteria before shipping, using `example/`: + +1. **SPM, Core only** β€” `flutter config --enable-swift-package-manager && cd example && flutter clean && flutter build ios --no-codesign`. Confirm init/start/event-logging Dart APIs reach the native layer (existing `example/` app coverage). +2. **CocoaPods, Core only** (`$AppsFlyerPurchaseConnector` unset) β€” `flutter config --no-enable-swift-package-manager && cd example && flutter clean && pod install && flutter build ios --no-codesign`. Confirm behavior is bit-for-bit identical to pre-change (regression check). +3. **CocoaPods, Core + PurchaseConnector** (`$AppsFlyerPurchaseConnector = true` in `example/ios/Podfile`) β€” same as above with the flag set. Confirm Purchase Connector channel still registers and responds. +4. **Explicitly not required this release**: SPM + PurchaseConnector β€” confirm it's genuinely absent/inert per the corrected failure-mode analysis above (attempt calling a Purchase Connector API from an SPM-only build and confirm it raises `MissingPluginException`, matching the documented limitation rather than crashing or hanging). + +All four must be run on a real device build, not just `--no-codesign`, before Alice's implementation review is requested β€” `--no-codesign` only proves compilation succeeds, not that the native SDK initializes and channels respond. + +## Documentation impact (flag only β€” action in Phase 3) + +- **F-054** (`docs/features/F-054-purchase-connector-build-time-opt-in.md`): add SPM as a third gating path in its Call Chain/Files sections, and add the corrected failure-mode bullet to Known Limitations (see above) once implementation lands. +- **F-060** (new): this feature's own catalog entry, written in Phase 3 from the real implemented code β€” supersedes the placeholder discussion from earlier in this session; do not reuse any earlier draft. +- `CHANGELOG.md` and release notes (PRD requirement 5): document SPM support added for Core, PurchaseConnector's continued CocoaPods-only status, and link flutter/flutter#161182 for apps tracking when that might change. + +## Open questions resolved by this design + +- Package.swift path: confirmed `ios/appsflyer_sdk/Package.swift` against the official Flutter guide (not just the drafts) β€” correct. +- podspec marker: none needed β€” presence of `Package.swift` at the conventional path is the only signal Flutter tooling requires. +- Compile-time signal for Purchase Connector-without-CocoaPods: corrected from R-001's hypothesis β€” it's the same runtime `MissingPluginException` as today's CocoaPods opt-out, not a build-time error. Accepted as an existing known limitation, not a regression. diff --git a/ios/.gitignore b/ios/.gitignore index 710ec6cf..62364b2a 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -34,3 +34,6 @@ Icon? .tags* /Flutter/Generated.xcconfig + +.build/ +.swiftpm/ diff --git a/ios/appsflyer_sdk.podspec b/ios/appsflyer_sdk.podspec index 34981194..3c35e2f6 100644 --- a/ios/appsflyer_sdk.podspec +++ b/ios/appsflyer_sdk.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'appsflyer_sdk' - s.version = '6.18.0' + s.version = '6.18.1' s.summary = 'AppsFlyer Integration for Flutter' s.description = 'AppsFlyer is the market leader in mobile advertising attribution & analytics, helping marketers to pinpoint their targeting, optimize their ad spend and boost their ROI.' s.homepage = 'https://github.com/AppsFlyerSDK/flutter_appsflyer_sdk' @@ -18,15 +18,15 @@ Pod::Spec.new do |s| end s.subspec 'Core' do |ss| - ss.source_files = 'Classes/**/*' - ss.public_header_files = 'Classes/**/*.h' + ss.source_files = 'appsflyer_sdk/Sources/appsflyer_sdk/**/*.{h,m}' + ss.public_header_files = 'appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/*.h' ss.dependency 'Flutter' - ss.ios.dependency 'AppsFlyerFramework','6.18.0' + ss.ios.dependency 'AppsFlyerFramework','6.18.1' end s.subspec 'PurchaseConnector' do |ss| ss.dependency 'Flutter' - ss.ios.dependency 'PurchaseConnector', '6.18.0' + ss.ios.dependency 'PurchaseConnector', '6.18.2' ss.source_files = 'PurchaseConnector/**/*' ss.public_header_files = 'PurchaseConnector/**/*.h' diff --git a/ios/appsflyer_sdk/Package.swift b/ios/appsflyer_sdk/Package.swift new file mode 100644 index 00000000..69faf72a --- /dev/null +++ b/ios/appsflyer_sdk/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "appsflyer_sdk", + platforms: [.iOS("12.0")], + products: [ + .library(name: "appsflyer-sdk", targets: ["appsflyer_sdk"]) + ], + dependencies: [ + .package(url: "https://github.com/AppsFlyerSDK/AppsFlyerFramework.git", .exact("6.18.0")) + ], + targets: [ + .target( + name: "appsflyer_sdk", + dependencies: [ + .product(name: "AppsFlyerLib", package: "AppsFlyerFramework") + ], + cSettings: [ + .headerSearchPath("include/appsflyer_sdk") + ] + ) + ] +) diff --git a/ios/Classes/AppsFlyerAttribution.m b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m similarity index 100% rename from ios/Classes/AppsFlyerAttribution.m rename to ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerAttribution.m diff --git a/ios/Classes/AppsFlyerStreamHandler.m b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m similarity index 100% rename from ios/Classes/AppsFlyerStreamHandler.m rename to ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m diff --git a/ios/Classes/AppsflyerSdkPlugin.m b/ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m similarity index 100% rename from ios/Classes/AppsflyerSdkPlugin.m rename to ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m diff --git a/ios/Classes/AppsFlyerAttribution.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerAttribution.h similarity index 100% rename from ios/Classes/AppsFlyerAttribution.h rename to ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerAttribution.h diff --git a/ios/Classes/AppsFlyerStreamHandler.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerStreamHandler.h similarity index 100% rename from ios/Classes/AppsFlyerStreamHandler.h rename to ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsFlyerStreamHandler.h diff --git a/ios/Classes/AppsflyerSdkPlugin.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h similarity index 100% rename from ios/Classes/AppsflyerSdkPlugin.h rename to ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h diff --git a/ios/Classes/FlutterAppDelegate+AppsFlyerStreamHandler.h b/ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/FlutterAppDelegate+AppsFlyerStreamHandler.h similarity index 100% rename from ios/Classes/FlutterAppDelegate+AppsFlyerStreamHandler.h rename to ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/FlutterAppDelegate+AppsFlyerStreamHandler.h diff --git a/pubspec.yaml b/pubspec.yaml index b84f8b63..52c25c7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: appsflyer_sdk description: A Flutter plugin for AppsFlyer SDK. Supports iOS and Android. -version: 6.18.0 +version: 6.18.1-rc1 homepage: https://github.com/AppsFlyerSDK/flutter_appsflyer_sdk