From cb8f9dc08548226058e45fd0205ba5f84bb099a3 Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 8 May 2026 19:21:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9A=94=E7=A6=BB=EF=BC=9A=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 6 + design/pencil-app-client.pen | 428 +++++++++++++- docs/rewrite_refactor_playbook.md | 679 +++++++++++++++++++++++ ios/Runner/Info.plist | 4 + lib/app.dart | 18 + lib/core/auth/auth_service.dart | 14 + lib/core/referrer/referrer_service.dart | 142 +++++ pubspec.yaml | 2 +- 8 files changed, 1275 insertions(+), 18 deletions(-) create mode 100644 docs/rewrite_refactor_playbook.md diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5b81ffb..185907f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,12 @@ android:maxSdkVersion="32" tools:replace="android:maxSdkVersion" /> + + + + + + $(FLUTTER_BUILD_NUMBER) FacebookAutoLogAppEventsEnabled + LSApplicationQueriesSchemes + + fb + LSRequiresIPhoneOS NSCameraUsageDescription diff --git a/lib/app.dart b/lib/app.dart index d9536e3..bbf9c4c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:facebook_app_events/facebook_app_events.dart'; import 'package:flutter/material.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'core/auth/auth_service.dart'; import 'core/nav/app_route_observer.dart'; @@ -46,6 +47,7 @@ class _AppState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { // Remove native splash; Flutter login overlay takes over if startup is still running. FlutterNativeSplash.remove(); + unawaited(_logFacebookInstallState('first_frame')); _reportFacebookActivateApp('first_frame'); }); } @@ -65,6 +67,22 @@ class _AppState extends State with WidgetsBindingObserver { }); } + /// Detect whether a local app can handle Facebook's URL scheme. + /// + /// iOS requires `LSApplicationQueriesSchemes` to include `fb`; Android 11+ + /// requires a matching `` entry in AndroidManifest. + Future _logFacebookInstallState(String reason) async { + const scheme = 'fb://'; + try { + final installed = await canLaunchUrl(Uri.parse(scheme)); + _fbLog.d( + 'Facebook app install check ($reason): installed=$installed scheme=$scheme', + ); + } catch (e, st) { + _fbLog.e('Facebook app install check failed ($reason)', e, st); + } + } + /// Foreground resume; cold start also uses addPostFrameCallback in initState. @override void didChangeAppLifecycleState(AppLifecycleState state) { diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index 77b7a81..461ca61 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -553,6 +553,20 @@ class AuthService { static Future _runPostLoginReferrerWork(String uid, String deviceId) async { try { await _reportBothReferrersAndRefreshCommonInfo(uid, deviceId); + // 首次归因可能是“延迟到的自然量”:开启 5s 轮询、最长 5min。 + // 轮询结束后,无论是否检测到非自然量都重新上报归因 + 刷新 common_info, + // 再触发首页完整重载(_loadCategories 会重拉分类数据)。 + unawaited(ReferrerService.startOrganicPolling( + onStopped: (detectedNonOrganic) async { + try { + await _reportBothReferrersAndRefreshCommonInfo(uid, deviceId); + } catch (e, st) { + _logMsg('organic poll: 重新上报归因/common_info 异常: $e'); + _logMsg('organic poll: 堆栈 $st'); + } + UserState.requestHomeFullReload(); + }, + )); } catch (e, st) { _logMsg('referrer/common_info 后台任务异常: $e'); _logMsg('referrer/common_info 堆栈: $st'); diff --git a/lib/core/referrer/referrer_service.dart b/lib/core/referrer/referrer_service.dart index 86ab86f..fdce89f 100644 --- a/lib/core/referrer/referrer_service.dart +++ b/lib/core/referrer/referrer_service.dart @@ -6,10 +6,14 @@ import 'package:adjust_sdk/adjust.dart'; import 'package:flutter/foundation.dart'; import 'package:play_install_referrer/play_install_referrer.dart'; +import '../log/app_logger.dart'; + /// 归因信息服务(优先从 Adjust 获取,fallback 使用 Play Install Referrer) class ReferrerService { ReferrerService._(); + static final _log = AppLogger('ReferrerService'); + static String? _cachedReferrer; static String _referrerSource = 'gg'; static final Completer _completer = Completer(); @@ -27,6 +31,15 @@ class ReferrerService { /// Play Install Referrer 读取超时(部分机型/系统上可能极慢) static const Duration _playInstallReferrerTimeout = Duration(seconds: 10); + /// 自然量轮询:每次检查间隔 + static const Duration _organicPollInterval = Duration(seconds: 5); + + /// 自然量轮询:最长持续时间(达到后无论是否仍是自然量都停止) + static const Duration _organicPollMaxDuration = Duration(minutes: 5); + + static bool _organicPollRunning = false; + static Timer? _organicPollTimer; + /// 由 Adjust attributionCallback 调用,首次安装时归因往往通过此回调返回(晚于 getAttributionWithTimeout) static void receiveAttributionFromCallback(AdjustAttribution attribution) { if (!_attributionCallbackCompleter.isCompleted) { @@ -143,4 +156,133 @@ class ReferrerService { return ''; } } + + /// 启动“自然量→渠道”轮询:仅当 Adjust 与 Play Install Referrer 当前都判定为自然量时开始; + /// 之后每 [_organicPollInterval] 重新判定一次,直到任一渠道变为非自然量或超过 + /// [_organicPollMaxDuration]。结束时调用 [onStopped],参数表示是否检测到非自然量。 + /// + /// 多次调用是幂等的:已在轮询时直接返回。 + static Future startOrganicPolling({ + required Future Function(bool detectedNonOrganic) onStopped, + }) async { + if (_organicPollRunning) { + _log.d('startOrganicPolling: already running, skip'); + return; + } + + final initiallyOrganic = await _isCurrentlyOrganic(); + if (!initiallyOrganic) { + _log.d('startOrganicPolling: 初始归因即为渠道,无需轮询'); + return; + } + + _organicPollRunning = true; + final startedAt = DateTime.now(); + var tickCount = 0; + _log.d( + 'startOrganicPolling: 启动自然量轮询,每 ${_organicPollInterval.inSeconds}s 一次,最长 ${_organicPollMaxDuration.inMinutes}min'); + + Future finish(bool detectedNonOrganic, {required String reason}) async { + _stopOrganicPoll(); + _log.d('startOrganicPolling: 结束,原因=$reason changed=$detectedNonOrganic'); + try { + await onStopped(detectedNonOrganic); + } catch (e, st) { + _log.d('startOrganicPolling: onStopped 回调异常: $e'); + _log.d('startOrganicPolling: stack $st'); + } + } + + Future tick() async { + if (!_organicPollRunning) return; + + tickCount++; + final elapsed = DateTime.now().difference(startedAt); + if (elapsed >= _organicPollMaxDuration) { + await finish(false, reason: 'timeout'); + return; + } + + final adjustNetwork = await _readAdjustNetwork(); + final adjustOrganic = _attributionIsOrganicByNetwork(adjustNetwork); + final pirRef = await _readPlayInstallReferrerRaw(); + final pirOrganic = _installReferrerIsOrganic(pirRef); + final stillOrganic = adjustOrganic && pirOrganic; + _log.d( + 'organic poll tick#$tickCount elapsed=${elapsed.inSeconds}s ' + 'adjustNetwork="${adjustNetwork ?? ''}" adjustOrganic=$adjustOrganic ' + 'pir="$pirRef" pirOrganic=$pirOrganic stillOrganic=$stillOrganic'); + + if (!_organicPollRunning) return; + + if (!stillOrganic) { + await finish(true, reason: 'detectedNonOrganic'); + return; + } + + _organicPollTimer = Timer(_organicPollInterval, () { + if (!_organicPollRunning) return; + unawaited(tick()); + }); + } + + _organicPollTimer = Timer(_organicPollInterval, () { + if (!_organicPollRunning) return; + unawaited(tick()); + }); + } + + static void _stopOrganicPoll() { + _organicPollTimer?.cancel(); + _organicPollTimer = null; + _organicPollRunning = false; + } + + /// 当 Adjust 归因和 Play Install Referrer 都判定为自然量时返回 true + static Future _isCurrentlyOrganic() async { + final adjustNetwork = await _readAdjustNetwork(); + final adjustOrganic = _attributionIsOrganicByNetwork(adjustNetwork); + final pirRef = await _readPlayInstallReferrerRaw(); + final pirOrganic = _installReferrerIsOrganic(pirRef); + return adjustOrganic && pirOrganic; + } + + /// 读取 Adjust 当前归因的 network 字段(异常返回 null,按未归因处理) + static Future _readAdjustNetwork() async { + try { + final attr = await Adjust.getAttribution(); + return attr.network; + } catch (_) { + return null; + } + } + + /// network 为空 / 'organic' 视为自然量(含未归因情况) + static bool _attributionIsOrganicByNetwork(String? network) { + final n = (network ?? '').trim().toLowerCase(); + return n.isEmpty || n == 'organic'; + } + + /// 读取 Play Install Referrer 原始字符串(非 Android / 异常返回空串) + static Future _readPlayInstallReferrerRaw() async { + if (defaultTargetPlatform != TargetPlatform.android) return ''; + try { + final details = await PlayInstallReferrer.installReferrer + .timeout(_playInstallReferrerTimeout); + return details.installReferrer ?? ''; + } catch (_) { + return ''; + } + } + + /// Google Play 自然量典型 referrer:`utm_source=google-play&utm_medium=organic`; + /// 空串/缺少 `utm_medium=` 视为尚未归因,按自然量处理。 + static bool _installReferrerIsOrganic(String ref) { + final s = ref.trim(); + if (s.isEmpty) return true; + final lower = s.toLowerCase(); + if (lower.contains('utm_medium=organic')) return true; + if (!lower.contains('utm_medium=')) return true; + return false; + } } diff --git a/pubspec.yaml b/pubspec.yaml index 886e263..bbfd743 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pets_hero_ai description: PetsHero AI Application. publish_to: 'none' -version: 1.2.0+120 +version: 1.2.1+121 environment: sdk: '>=3.0.0 <4.0.0'