Skip to content

Commit 557107b

Browse files
Merge branch 'dev' into release
2 parents 2554856 + b12c73d commit 557107b

9 files changed

Lines changed: 1290 additions & 208 deletions

File tree

docs/AD_ARCHITECTURE_REFACTOR.md

Lines changed: 645 additions & 0 deletions
Large diffs are not rendered by default.

lib/app/ad_director_provider.dart

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -329,17 +329,18 @@ class AdStrategyManager {
329329
/// - adEnvironmentProvider: To determine which strategies to create
330330
///
331331
/// Lifecycle:
332-
/// - autoDispose: Cleans up when no longer watched
333-
/// - ref.onDispose: Ensures manager.dispose() is called
334-
final adStrategyManagerProvider = Provider.autoDispose<AdStrategyManager?>((
332+
/// - Session-scoped provider (NOT autoDispose) to prevent recreation churn
333+
/// - Manager holds static NativeAd which should persist across widget rebuilds
334+
/// - ref.onDispose: Ensures manager.dispose() is called on app exit
335+
final adStrategyManagerProvider = Provider<AdStrategyManager?>((
335336
ref,
336337
) {
337338
// Wait for environment to be ready
338339
final environmentAsync = ref.watch(adEnvironmentProvider);
339340

340341
return environmentAsync.when(
341342
data: (environment) {
342-
debugPrint('📦 Creating AdStrategyManager from provider');
343+
debugPrint('📦 Creating AdStrategyManager (session-scoped)');
343344

344345
// Create manager with environment
345346
final manager = AdStrategyManager.create(
@@ -351,9 +352,9 @@ final adStrategyManagerProvider = Provider.autoDispose<AdStrategyManager?>((
351352
// (actual ad loading happens on connection changes, which is async)
352353
manager.initialize();
353354

354-
// Register disposal
355+
// Register disposal (only happens on app exit now)
355356
ref.onDispose(() {
356-
debugPrint('📦 AdStrategyManager provider disposing');
357+
debugPrint('📦 AdStrategyManager disposing (app exit)');
357358
manager.dispose();
358359
});
359360

lib/app/app.dart

Lines changed: 52 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
import 'dart:io';
2-
31
import 'package:defyx_vpn/app/ad_director_provider.dart';
42
import 'package:defyx_vpn/app/router/app_router.dart';
53
import 'package:defyx_vpn/core/theme/app_theme.dart';
64
import 'package:defyx_vpn/modules/core/vpn.dart';
75
import 'package:defyx_vpn/modules/core/desktop_platform_handler.dart';
86
import 'package:defyx_vpn/modules/main/presentation/widgets/ump_service.dart';
97
import 'package:defyx_vpn/shared/providers/language_provider.dart';
10-
import 'package:defyx_vpn/shared/providers/ad_personalization_provider.dart';
8+
import 'package:defyx_vpn/shared/providers/ad_readiness_coordinator.dart';
119
import 'package:defyx_vpn/shared/providers/connection_state_provider.dart';
1210
import 'package:flutter/foundation.dart';
1311
import 'package:flutter/material.dart';
1412
import 'package:flutter_screenutil/flutter_screenutil.dart';
1513
import 'package:flutter_riverpod/flutter_riverpod.dart';
16-
import 'package:google_mobile_ads/google_mobile_ads.dart';
1714
import 'package:defyx_vpn/shared/services/animation_service.dart';
1815
import 'package:defyx_vpn/shared/services/alert_service.dart';
1916
import 'package:toastification/toastification.dart';
@@ -28,36 +25,42 @@ class App extends ConsumerWidget {
2825
@override
2926
Widget build(BuildContext context, WidgetRef ref) {
3027
// Eagerly trigger environment computation
31-
ref.read(adEnvironmentProvider);
32-
33-
// Listen for VPN profile setup to trigger AdMob initialization
34-
ref.listen<AdPersonalizationState>(adPersonalizationProvider, (
35-
previous,
36-
next,
37-
) {
38-
// When VPN profile is setup, trigger AdMob initialization
39-
if (next.vpnProfileSetup && (previous?.vpnProfileSetup != true)) {
40-
debugPrint(
41-
'🚀 VPN profile setup detected - triggering AdMob initialization',
42-
);
43-
_handleAdConfiguration(ref);
28+
final environmentAsync = ref.watch(adEnvironmentProvider);
29+
30+
// Single listener for ad readiness state changes
31+
ref.listen(adReadinessCoordinatorProvider, (previous, next) {
32+
if (previous == null) return;
33+
34+
// When canInitializeAdMob transitions to true, start the flow
35+
if (next.canInitializeAdMob && !previous.canInitializeAdMob) {
36+
debugPrint('🚀 Privacy accepted - starting ad initialization flow');
37+
38+
environmentAsync.whenData((environment) {
39+
if (environment.shouldInitializeAdMob) {
40+
_initializeAdFlow(ref);
41+
} else {
42+
debugPrint(
43+
'📱 Using internal ads only (${environment.isIranian ? "Iranian user" : "desktop platform"})',
44+
);
45+
}
46+
});
47+
}
48+
49+
// When consent completes and we're disconnected, retry ad load
50+
if (next.canLoadAds && !previous.canLoadAds) {
51+
final connectionState = ref.read(connectionStateProvider).status;
52+
if (connectionState == ConnectionStatus.disconnected) {
53+
debugPrint('✅ Consent complete & disconnected - triggering ad load');
54+
Future.delayed(const Duration(milliseconds: 100), () {
55+
ref.read(adStrategyManagerProvider)?.retryGoogleAdLoad();
56+
});
57+
}
4458
}
4559
});
4660

4761
return FutureBuilder<void>(
4862
future: _initializeApp(ref),
49-
builder: (context, snapshot) {
50-
// Check immediately on build
51-
_handleAdConfiguration(ref);
52-
53-
// Also check after frame to ensure persisted state is loaded
54-
WidgetsBinding.instance.addPostFrameCallback((_) {
55-
debugPrint('📱 Post-frame callback - rechecking AdMob initialization');
56-
_handleAdConfiguration(ref);
57-
});
58-
59-
return _buildApp(context, ref);
60-
},
63+
builder: (context, snapshot) => _buildApp(context, ref),
6164
);
6265
}
6366

@@ -67,96 +70,28 @@ class App extends ConsumerWidget {
6770
await AnimationService().init();
6871
}
6972

70-
void _handleAdConfiguration(WidgetRef ref) {
71-
// Use adEnvironmentProvider to decide AdMob initialization
72-
final environmentAsync = ref.read(adEnvironmentProvider);
73-
74-
environmentAsync.whenData((environment) {
75-
if (!environment.shouldInitializeAdMob) {
76-
debugPrint(
77-
'📱 Using internal ads only (${environment.isIranian ? "Iranian user" : "desktop platform"})',
78-
);
79-
} else {
80-
// Only initialize AdMob after VPN profile is setup (privacy notice accepted)
81-
final adState = ref.read(adPersonalizationProvider);
82-
debugPrint(
83-
'🔍 AdMob check: vpnProfileSetup=${adState.vpnProfileSetup}, adMobInitializationStarted=${adState.adMobInitializationStarted}',
84-
);
85-
86-
if (adState.vpnProfileSetup && !adState.adMobInitializationStarted) {
87-
debugPrint('📱 VPN profile ready - Initializing AdMob');
88-
89-
// Defer state modification to avoid modifying provider during build
90-
Future.microtask(() {
91-
ref
92-
.read(adPersonalizationProvider.notifier)
93-
.markAdMobInitializationStarted();
94-
_initializeMobileAdsWithConsent(ref, environment);
95-
});
96-
} else if (!adState.vpnProfileSetup) {
97-
debugPrint(
98-
'⏳ Waiting for VPN profile setup before initializing AdMob',
73+
/// Initialize ad flow using the coordinator
74+
void _initializeAdFlow(WidgetRef ref) {
75+
final coordinator = ref.read(adReadinessCoordinatorProvider.notifier);
76+
final umpService = ref.read(umpServiceProvider);
77+
78+
coordinator.initializeAdFlow(
79+
onRunUMP: (shouldRequestUMP) async {
80+
if (shouldRequestUMP) {
81+
debugPrint('🔐 Running UMP consent flow...');
82+
await umpService.requestConsentWithATT(
83+
ref: ref,
84+
onDone: () {
85+
debugPrint('✅ UMP flow complete - marking consent done');
86+
coordinator.markConsentComplete();
87+
},
9988
);
100-
} else if (adState.adMobInitializationStarted) {
101-
debugPrint('✅ AdMob already initialized, skipping');
89+
} else {
90+
debugPrint('⏭️ Skipping UMP (ATT denied/restricted)');
91+
coordinator.markConsentComplete();
10292
}
103-
}
104-
});
105-
}
106-
107-
Future<void> _initializeMobileAdsWithConsent(
108-
WidgetRef ref,
109-
AdEnvironment environment,
110-
) async {
111-
try {
112-
// Environment already verified shouldInitializeAdMob = true
113-
debugPrint('📱 Starting AdMob initialization...');
114-
115-
if (Platform.isAndroid || Platform.isIOS) {
116-
// Request App Tracking Transparency (iOS only)
117-
if (Platform.isIOS) {
118-
// Small delay to ensure UI is ready
119-
await Future.delayed(const Duration(milliseconds: 500));
120-
121-
// Request ATT authorization - this shows dialog and stores result
122-
final status = await ref
123-
.read(adPersonalizationProvider.notifier)
124-
.requestATT();
125-
debugPrint('📱 ATT dialog result: ${status.name}');
126-
}
127-
128-
// UMP service will handle ATT status and decide whether to show consent
129-
final umpService = ref.read(umpServiceProvider);
130-
await umpService.requestConsentWithATT(
131-
ref: ref,
132-
onDone: () async {
133-
// Initialize Mobile Ads after consent flow completes
134-
await MobileAds.instance.initialize();
135-
debugPrint('📱 Google AdMob initialized');
136-
137-
// Mark consent flow as complete - NOW SAFE TO LOAD ADS
138-
ref
139-
.read(adPersonalizationProvider.notifier)
140-
.markConsentFlowComplete();
141-
142-
// Trigger ad loading retry if still disconnected
143-
final connectionState = ref.read(connectionStateProvider).status;
144-
if (connectionState == ConnectionStatus.disconnected) {
145-
debugPrint(
146-
'🔄 Consent complete & disconnected - triggering ad load retry',
147-
);
148-
// Small delay to ensure state propagation
149-
await Future.delayed(const Duration(milliseconds: 100));
150-
151-
final manager = ref.read(adStrategyManagerProvider);
152-
manager?.retryGoogleAdLoad();
153-
}
154-
},
155-
);
156-
}
157-
} catch (error) {
158-
debugPrint('Error initializing Google AdMob: $error');
159-
}
93+
},
94+
);
16095
}
16196

16297
Widget _buildApp(BuildContext context, WidgetRef ref) {

lib/modules/main/presentation/screens/main_screen.dart

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import 'package:defyx_vpn/shared/layout/main_screen_background.dart';
1818
import 'package:defyx_vpn/modules/main/presentation/widgets/header_section.dart';
1919
import 'package:defyx_vpn/modules/main/presentation/widgets/tips_slider_section.dart';
2020
import 'package:defyx_vpn/shared/providers/connection_state_provider.dart';
21-
import 'package:defyx_vpn/shared/providers/ad_personalization_provider.dart';
21+
import 'package:defyx_vpn/shared/providers/ad_readiness_coordinator.dart';
2222
import 'package:defyx_vpn/shared/services/animation_service.dart';
2323
import 'package:flutter/material.dart';
2424
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -58,7 +58,13 @@ class _MainScreenState extends ConsumerState<MainScreen> {
5858

5959
WidgetsBinding.instance.addPostFrameCallback((_) async {
6060
_logic.checkAndReconnect();
61-
await _logic.checkAndShowPrivacyNotice(_showPrivacyNoticeDialog);
61+
62+
// Check if privacy notice should be shown using coordinator
63+
final adReadiness = ref.read(adReadinessCoordinatorProvider);
64+
if (adReadiness.canShowPrivacyDialog) {
65+
_showPrivacyNoticeDialog();
66+
}
67+
6268
_checkInitialConnectionState();
6369

6470
if (!(Platform.isAndroid || Platform.isIOS)) {
@@ -137,35 +143,24 @@ class _MainScreenState extends ConsumerState<MainScreen> {
137143
void _showPrivacyNoticeDialog() {
138144
PrivacyNoticeDialog.show(context, () async {
139145
if (ref.context.mounted) {
146+
// 1. Prepare VPN profile
140147
final vpnBridge = VpnBridge();
141148
final result = await vpnBridge.prepareVpn();
149+
142150
if (result && ref.context.mounted) {
151+
// 2. Initialize VPN
143152
final vpn = VPN(ProviderScope.containerOf(ref.context));
144153
await vpn.initVPN();
154+
155+
// 3. Save settings
145156
await ref.read(settingsProvider.notifier).saveState();
146-
await _logic.markPrivacyNoticeShown();
147-
148-
// Mark VPN profile setup complete - NOW SAFE TO LOAD ADS
149-
ref.read(adPersonalizationProvider.notifier).markVpnProfileSetup();
150-
debugPrint(
151-
'✅ Privacy accepted & VPN profile setup - ads can now load',
152-
);
153-
154-
// Trigger ad loading retry if consent was already complete
155-
final consentState = ref.read(adPersonalizationProvider);
156-
if (consentState.consentFlowComplete) {
157-
final connectionState = ref.read(connectionStateProvider).status;
158-
if (connectionState == ConnectionStatus.disconnected) {
159-
debugPrint(
160-
'🔄 VPN setup complete & disconnected - triggering ad load retry',
161-
);
162-
// Small delay to ensure state propagation
163-
await Future.delayed(const Duration(milliseconds: 100));
164-
165-
final manager = ref.read(adStrategyManagerProvider);
166-
manager?.retryGoogleAdLoad();
167-
}
168-
}
157+
158+
// 4. Mark privacy accepted in coordinator (replaces old scattered state)
159+
await ref
160+
.read(adReadinessCoordinatorProvider.notifier)
161+
.markPrivacyAccepted();
162+
163+
debugPrint('✅ Privacy accepted - coordinator will handle ad init');
169164

170165
if (!(Platform.isAndroid || Platform.isIOS)) {
171166
await _logic.triggerAutoConnectIfEnabled();

lib/modules/main/presentation/widgets/ads/strategy/google_ad_strategy.dart

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import 'package:google_mobile_ads/google_mobile_ads.dart';
99
import 'package:defyx_vpn/modules/core/network.dart';
1010
import 'package:defyx_vpn/shared/services/firebase_analytics_service.dart';
1111
import 'package:defyx_vpn/shared/providers/connection_state_provider.dart';
12-
import 'package:defyx_vpn/shared/providers/ad_personalization_provider.dart';
12+
import 'package:defyx_vpn/shared/providers/ad_readiness_coordinator.dart';
1313
import '../ads_state.dart';
1414
import '../models/ad_load_result.dart';
1515
import 'ad_loading_strategy.dart';
@@ -143,23 +143,16 @@ class GoogleAdStrategy implements AdLoadingStrategy {
143143
);
144144
}
145145

146-
// CRITICAL: Wait for consent flow AND VPN profile setup to complete first
147-
final consentState = ref.read(adPersonalizationProvider);
148-
if (!consentState.consentFlowComplete) {
149-
debugPrint('⏳ Consent flow not complete yet - skipping ad load');
146+
// CRITICAL: Check ad readiness (privacy accepted + consent complete + AdMob initialized)
147+
final adReadiness = ref.read(adReadinessCoordinatorProvider);
148+
if (!adReadiness.canLoadAds) {
149+
debugPrint('⏳ Ads not ready yet: $adReadiness');
150150
return AdLoadResult.failure(
151-
errorCode: 'CONSENT_PENDING',
152-
errorMessage: 'Waiting for ATT/UMP consent flow to complete',
151+
errorCode: 'AD_READINESS_PENDING',
152+
errorMessage: 'Privacy/consent/initialization not complete',
153153
);
154154
}
155-
if (!consentState.vpnProfileSetup) {
156-
debugPrint('⏳ VPN profile not setup yet - skipping ad load');
157-
return AdLoadResult.failure(
158-
errorCode: 'VPN_SETUP_PENDING',
159-
errorMessage: 'Waiting for VPN profile setup to complete',
160-
);
161-
}
162-
debugPrint('✅ Consent flow & VPN profile ready - proceeding with ad load');
155+
debugPrint('✅ Ad readiness verified - proceeding with ad load');
163156

164157
// CRITICAL: Never load ads while VPN is connected (need real IP for targeting)
165158
final connectionState = ref.read(connectionStateProvider).status;
@@ -359,14 +352,14 @@ class GoogleAdStrategy implements AdLoadingStrategy {
359352
],
360353
contentUrl: 'https://defyxvpn.com',
361354
nonPersonalizedAds: !ref
362-
.read(adPersonalizationProvider)
355+
.read(adReadinessCoordinatorProvider)
363356
.canUsePersonalizedAds,
364357
extras: {
365358
'app_category': 'utilities',
366359
'app_subcategory': 'vpn',
367360
'placement': 'main_screen_disconnected',
368361
...ref
369-
.read(adPersonalizationProvider.notifier)
362+
.read(adReadinessCoordinatorProvider.notifier)
370363
.getAdRequestExtras(),
371364
},
372365
),

0 commit comments

Comments
 (0)