Files
obsidian-vault/work/projects/ios-subscription-day7-promo-pr.md
T

190 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1214798984829406
Tech Design URL: N/A
CC: N/A
### Description
- Adds `SubscriptionPromoExistingUserCoordinator` — a new launch-prompt coordinator targeting existing users (7+ days since install) who completed linear onboarding normally and have never seen a subscription offer. Shows a half-sheet with a CTA to the purchase flow using the `funnel_modal_ios__subscriptionnudge` funnel origin.
- Adds `subscriptionPromoForExistingUsers` remote feature flag (backed by `PrivacyProSubfeature.subscriptionPromoForExistingUsers`), guarded additionally by the existing `privacyProOnboardingPromotion` gate.
- Adds `SubscriptionFunnelOrigin.existingUserPromo` (`funnel_modal_ios__subscriptionnudge`) and three pixel events: `m_privacy-pro_existing_user_promotion_impression`, `…_tap`, `…_dismiss`.
- Refactors the per-provider onboarding gate: removes the global `hasSeenOnboarding` guard from `ModalPromptCoordinationService` and replaces it with a per-provider `isEligibleToPresent(isOnboardingComplete: Bool) -> Bool` method on `ModalPromptProvider`. The manager resolves the onboarding state once and passes it to each provider, keeping a single source of truth.
- `SubscriptionPromoExistingUserCoordinator` implements a softer gate: eligible when onboarding is complete **or** no contextual onboarding dialog is currently visible on screen (`isShowingContextualOnboardingDialog`) — allowing single-tab users who never reached the fire/EOJ flow to see the launch promo, while preventing the promo from appearing on top of active onboarding dialogs on cold launch.
- All other providers (`WinBackOffer`, `DefaultBrowser`, `NewAddressBarPicker`, `WhatsNew`) use the default implementation, which returns the passed `isOnboardingComplete` value — preserving their previous behaviour without each owning an `onboardingStatusProvider` dependency.
- Inserts `subscriptionPromoExistingUser` at priority 3 in the modal prompt queue (after reinstaller promo, before address bar picker).
- Adds `SubscriptionPromoUITests` covering promo appearance after onboarding and suppression while contextual onboarding dialogs are active.
### Testing Steps
**Prerequisites**
- Build from `alex/subscription-day7-promo`.
- Be an Internal User (add `|| DEBUG` to the `#if` check in `InternalUserStore.swift`).
- The cooldown is currently overridden to **1 minute** from install date for easier manual testing (production will require 7 days).
- **"Stop app"** = force-quit via Xcode Stop button or app switcher swipe-up.
- **"Hide app"** = press home button / swipe up to background without quitting.
- **Day-7 promo sheet** = the "Did you know?" half-sheet (title: *"Did you know?"*, CTA: *"Try it free!"* or *"Learn More"*, dismiss: *"No thanks"*, close: ✕ icon top-right).
---
#### A — Linear onboarding "Search only" path
To reach this state: complete linear onboarding → on the *"Want easy access to private AI chat in the address bar?"* screen select **"Search only"** → tap **"Next"** → complete remaining steps.
Contextual onboarding then starts on the browser. The *"Ready to get started? Try a search!"* dialog appears with search suggestion chips.
1. **"Try a Search" dialog visible; hide app, restore; stop app, cold launch — no promo both times**
On the *"Ready to get started? Try a search!"* dialog, do not tap any chip → press home button → switch back to DuckDuckGo → **No promo sheet** (foregrounding) → force-quit → wait ≥1 min → cold launch
**No promo sheet**
2. **Select a search chip; "Try Visiting a Site" appears; hide app, restore; stop app, cold launch**
On *"Ready to get started? Try a search!"* select a search suggestion chip → SERP loads → *"That's DuckDuckGo Search!"* dialog → tap **"Got it!"** → *"Next, try visiting a site!"* appears → press home button → switch back to DuckDuckGo → **No promo sheet** (foregrounding; `isShowingContextualOnboardingDialog = true`) → force-quit → wait ≥1 min → cold launch
**Day-7 promo sheet appears** (`tryVisitASiteShown` already persisted; dialog won't reappear; `isShowingContextualOnboardingDialog = false` on fresh launch)
3. **"Try Visiting a Site" dialog visible; tap X; stop app; cold launch**
On *"Next, try visiting a site!"* tap the **X** (skip/close) → confirm skip if prompted → force-quit → wait ≥1 min → cold launch
**Day-7 promo sheet appears** (fire tutorial not started; `subscriptionPromotionPending = false`)
4. **Complete "Try Visiting a Site"; fire button tutorial shows; stop app; cold launch**
Visit a site from the *"Try Visiting a Site"* step → tracker dialog → **"Got it!"** → fire button is highlighted by tutorial → force-quit without tapping fire → wait ≥1 min → cold launch
**No promo sheet**
5. **Press 🔥 fire button; contextual subscription promo "Oh, before I forget..." appears; hide app, restore; stop app, cold launch — no promo both times**
Follow fire tutorial → tap the 🔥 **Fire button** → fire confirmation → contextual subscription promo *"Oh, before I forget..."* appears → press home button → switch back to DuckDuckGo → **No promo sheet** (foregrounding; `subscriptionPromotionPending = true`) → force-quit → wait ≥1 min → cold launch
**No promo sheet** (`subscriptionPromotionPending = true`; launch promo blocked to avoid clash with contextual flow)
6. **Tap "Try it free!" / "Learn More" on "Oh, before I forget..."; stop app; cold launch**
On the *"Oh, before I forget..."* contextual promo, tap **"Try it free!"** (or **"Learn More"**) → force-quit → wait ≥1 min → cold launch
**Day-7 promo sheet appears** (`hasSeenOnboarding = true`; launch promo not yet shown)
7. **Tap "No thanks" on "Oh, before I forget..."; stop app; cold launch**
On the *"Oh, before I forget..."* contextual promo, tap **"No thanks"** → force-quit → wait ≥1 min → cold launch
**Day-7 promo sheet appears** (`hasSeenOnboarding = true`)
---
#### B — Linear onboarding "Search & Duck.ai" path — search suggestion variant
To reach this state: complete linear onboarding → on the *"Want easy access to private AI chat in the address bar?"* screen select **"Toggle between Search and Duck.ai"** → tap **"Next"** → complete remaining steps.
Contextual onboarding starts with *"Ready to get started? Try a search or AI chat!"* dialog showing both search and Duck.ai suggestion chips.
8. **Select a search chip; "Try Visiting a Site" appears; stop app; cold launch**
On *"Ready to get started? Try a search or AI chat!"* select a **search suggestion chip** → SERP loads → *"That's DuckDuckGo Search!"**"Next, try visiting a site!"* dialog → force-quit → wait ≥1 min → cold launch
**No promo sheet**
9. **Select a search chip; complete contextual onboarding to fire step; contextual promo pending; stop app; cold launch**
Select a search chip → complete *"Try Visiting a Site"* → fire tutorial → tap 🔥 Fire button → *"Oh, before I forget..."* appears → force-quit → cold launch
**No promo sheet** (`subscriptionPromotionPending = true`)
10. **Select a search chip; complete full contextual onboarding including EOJ promo; stop app; cold launch**
Same as #9 but fully complete the contextual subscription promo (tap **"Try it free!"**, **"Learn More"**, or **"No thanks"**) → force-quit → cold launch
**Day-7 promo sheet appears** (`hasSeenOnboarding = true`)
---
#### C — Linear onboarding "Search & Duck.ai" path — AI suggestion variant
11. **Select a Duck.ai chip; dialog visible; hide app, restore; tap X; stop app, cold launch**
On *"Ready to get started? Try a search or AI chat!"* select a **Duck.ai suggestion chip** → Duck.ai opens → contextual duck.ai dialog appears → press home button → switch back to DuckDuckGo → **No promo sheet** (foregrounding) → tap **X** → force-quit → wait ≥1 min → cold launch
**Day-7 promo sheet appears** (before fire step; `subscriptionPromotionPending = false`)
12. **Select a Duck.ai chip; complete duck.ai dialogs; fire pending; stop app; cold launch**
Select Duck.ai chip → complete duck.ai contextual dialogs ("Got it!") → fire tutorial → tap 🔥 Fire button → *"Oh, before I forget..."* appears → force-quit → cold launch
**No promo sheet** (`subscriptionPromotionPending = true`)
13. **Select a Duck.ai chip; complete full contextual onboarding; stop app; cold launch**
Same as #12 but fully complete the contextual subscription promo → force-quit → cold launch
**Day-7 promo sheet appears** (`hasSeenOnboarding = true`)
---
#### D — Day-7 promo interaction flows (once promo appears)
14. **Tap "Try it free!" / "Learn More" CTA; verify destination; relaunch**
Trigger the day-7 promo (any flow above) → on the *"Did you know?"* sheet tap **"Try it free!"** (or **"Learn More"**) → Privacy Pro subscription page opens → force-quit → cold launch
→ Subscription page opened with `funnel_modal_ios__subscriptionnudge` origin; **no promo sheet on next launch**
15. **Tap "No thanks" (dismiss button); relaunch**
Trigger promo → tap **"No thanks"** → force-quit → cold launch
**No promo sheet** (already shown)
16. **Tap ✕ (close icon); relaunch**
Trigger promo → tap the **✕ icon** (top-right) → force-quit → cold launch
**No promo sheet** (already shown)
17. **Hide app while promo is visible; reopen; then cold launch**
Trigger promo → press home button without dismissing → switch back to DuckDuckGo → force-quit → cold launch
→ Promo was visible on reopen; **no promo sheet on next cold launch** (already shown)
---
#### E — Edge cases / negative flows
18. **Cooldown not passed: launch immediately after install**
Fresh install → enable flags → complete linear + contextual onboarding → cold launch immediately (< 1 min since install)
**No promo sheet** (cooldown not met)
19. **`subscriptionPromoForExistingUsers` flag disabled**
Disable `subscriptionPromoForExistingUsers` flag → complete onboarding → wait ≥1 min → cold launch
**No promo sheet**
20. **`privacyProOnboardingPromotion` flag disabled**
Disable `privacyProOnboardingPromotion` → same setup
**No promo sheet**
21. **WinBack-eligible user: WinBack promo takes priority**
Set up a user qualifying for both WinBack offer and day-7 promo → cold launch
**WinBack offer sheet appears** (higher priority); day-7 promo stays pending for the next launch after WinBack is dismissed
22. **Reinstaller-eligible user: reinstaller promo takes priority**
User who skipped linear onboarding (tap X → "Start Browsing") + `subscriptionPromoForReinstallers` enabled → cold launch
**Reinstaller promo sheet appears** (higher priority)
---
#### F — Other modal prompts not regressed by per-provider gate refactor
23. **Default Browser prompt does not appear before onboarding is complete**
User who has NOT completed contextual onboarding (`hasSeenOnboarding = false`, `subscriptionPromotionPending = false`, `subscriptionPromoForExistingUsers` disabled to prevent that promo) → cold launch
**No Default Browser prompt** (default `isEligibleToPresent` still requires onboarding complete)
*(Validates the per-provider gate refactor hasn't changed behaviour for other providers)*
---
#### G — Upgrade scenario
24. **Upgrade from pre-feature build; onboarding complete; promo shown on first launch after update**
Install build at commit `0f064b1e66` → complete linear + contextual onboarding all the way through the *"Oh, before I forget..."* contextual promo → update app to `alex/subscription-day7-promo` with flags enabled → cold launch (install date is ≥1 min ago)
**Day-7 promo sheet appears** (`hasSeenOnboarding = true` carried over; `subscriptionPromotionDialogSeen` = false since it's a new UserDefaults key; cooldown already passed)
25. **Upgrade from pre-feature build; contextual onboarding incomplete; no promo on first launch after update**
Install build at commit `0f064b1e66` → complete linear onboarding only (stop before completing contextual dialogs) → update to `alex/subscription-day7-promo` → cold launch (install date ≥1 min ago)
**No promo sheet**
---
### Impact and Risks
**Impact Level: Medium**
#### What could go wrong?
- **Promo appears during active contextual onboarding** — would create two overlapping subscription-related sheets. Mitigated by the `isEligibleToPresent` gate checking `isShowingContextualOnboardingDialog`; covered by testing flows #5, #9, #12.
- **Promo shown twice** — duplicate impression if `subscriptionPromotionDialogSeen` is not persisted correctly. Mitigated by setting the flag in `markLaunchPromptPresented()` before presenting; covered by flows #1416.
- **Other modal prompts regressed by gate refactor** — existing providers could break if the per-provider default gate behaves differently from the removed global gate. The default `isEligibleToPresent` returns `isOnboardingComplete`, which is functionally equivalent; covered by flow #23.
- **Cooldown bypassed by date manipulation** — low risk for production; the debug override (1-min) must be reverted before shipping.
### Quality Considerations
- **Privacy**: No new user data collected. Pixels carry only `returningUser` and `freeTrial` boolean parameters, consistent with existing subscription promo pixels.
- **Performance**: Eligibility check is synchronous and lightweight; no network calls added to the launch critical path.
- **Feature flag gating**: Double-gated (`subscriptionPromoForExistingUsers` + `privacyProOnboardingPromotion`), enabling gradual rollout and instant kill-switch.
- **Cooldown override**: The 1-minute override in `hasCooldownPassed()` must be reverted to the 7-day logic (`cooldownDays = 7`) before the flag is enabled in production. The TODO comment marks it clearly.
- **Funnel origin**: Uses a new distinct origin value (`funnel_modal_ios__subscriptionnudge`) so this promo's conversion can be tracked independently from the reinstaller and skipped-onboarding promos.
- **Test coverage**: `SubscriptionPromoExistingUserCoordinatorTests` covers eligibility logic, pixel firing, CTA navigation, and dismiss handling.
### Notes to Reviewer
- The `isEligibleToPresent(isOnboardingComplete:)` protocol method replaces the old global service-level `hasSeenOnboarding` guard. The net behaviour for existing providers is identical — they now check onboarding state per-provider via the default implementation rather than a single service gate. The only change in observable behaviour is that `SubscriptionPromoExistingUserCoordinator` can present to users who haven't fully completed contextual onboarding, as long as `isShowingContextualOnboardingDialog` is false.
- `hasAppStoreProductsAvailable` check is commented out in `shouldPresentLaunchPrompt()` — intentional for the current testing phase; evaluate whether to re-enable before rollout.
- The reinstaller promo coordinator (`SubscriptionPromoCoordinator`) and the existing-user coordinator share `SubscriptionPromoCoordinating` and `SubscriptionPromoModalPromptProvider`, keeping the sheet UI consistent.