From 65439a66b23030f0e5d1417606fcb556bdcb865a Mon Sep 17 00:00:00 2001 From: ivan Date: Tue, 14 Apr 2026 15:25:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=A6=81=E6=AD=A2?= =?UTF-8?q?=E6=88=AA=E5=B1=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/skin_config.json | 3 +- docs/funymee_app_development_manual.md | 6 +- docs/new_app_config_template.md | 2 +- lib/app.dart | 41 ++++++- lib/core/auth/auth_service.dart | 73 ++++++++++++ lib/features/generate/generate_screen.dart | 23 +--- lib/features/profile/profile_screen.dart | 124 ++++++++------------- lib/features/purchase/purchase_screen.dart | 18 ++- lib/features/report/report_screen.dart | 9 +- pubspec.lock | 8 ++ pubspec.yaml | 1 + 11 files changed, 198 insertions(+), 110 deletions(-) diff --git a/assets/skin_config.json b/assets/skin_config.json index 6fff429..4e5f5d8 100644 --- a/assets/skin_config.json +++ b/assets/skin_config.json @@ -71,6 +71,7 @@ "firstPurchase": "qlw4fp", "purchase": "b2ms4n", "register": "2k7vm5", + "paymentFailed": "", "price_599": "mtzzqk", "price_999": "m9ivl1", "price_1999": "kp7a52", @@ -80,7 +81,7 @@ "extConfig": { "keys": { "showVideoMenu": ["go_run", "need_wait"], - "allowScreenshot": ["screen"], + "forbidScreenshot": ["screen"], "blockScreenshot": ["safe_area"], "allowThirdPartyPayment": ["san_fang", "lucky"], "privacyUrl": ["privacy"], diff --git a/docs/funymee_app_development_manual.md b/docs/funymee_app_development_manual.md index 51986c1..beb4265 100644 --- a/docs/funymee_app_development_manual.md +++ b/docs/funymee_app_development_manual.md @@ -171,7 +171,7 @@ lib/ |------|------| | `ExtConfigRuntime.data` | `ValueNotifier`,监听后刷新首页 Tab / Grid | | `ExtConfigRuntime.commonInfoSucceeded` | `true` / `false` / `null`:是否成功拉到 common_info(**建议仅在为 `true` 时展示核心业务 UI**) | -| `ExtConfigData` | `showVideoMenu`、`allowScreenshot`、`allowThirdPartyPayment`、`privacyUrl`、`agreementUrl`、`items` | +| `ExtConfigData` | `showVideoMenu`、`forbidScreenshot`、`allowThirdPartyPayment`、`privacyUrl`、`agreementUrl`、`items` | | `ExtConfigItem` | 单项:`image`、`image_fix`、`img_need`、`cost`、`title`、`params` / `detail`;`taskExt` ⇒ `params ?? detail` | | `kExtConfigItemsCategoryId` | 固定 `-1`,作「静态 items Tab」分类 id | | `mergeHomeTabsWithExtConfigItems()` | `showVideoMenu == true` 时在 API Tab 列表 **末尾** 追加静态 Tab | @@ -182,7 +182,7 @@ lib/ | 语义 | 默认候选键(首个存在则生效) | |------|------------------------------| | 展示顶部 Video Tab 栏 + items 固定最后一格 | `go_run`、`need_wait` | -| 允许截屏 | `screen`;若无则看 `safe_area`(`true` ⇒ 不允许截屏) | +| 是否禁止截屏(逻辑 [ExtConfigData.forbidScreenshot]) | `screen`;若无则看 `safe_area`(均为 **`true` = 禁止截屏**) | | 允许第三方支付 | `san_fang`、`lucky` | | 隐私 / 协议 URL | `privacy`、`agreement` | | items 数组 | `items` | @@ -211,7 +211,7 @@ lib/ } ``` -首页逻辑建议(对齐 `app_client`):`await FrameworkAuthService.loginComplete` 后判断 `ExtConfigRuntime.commonInfoSucceeded.value == true` 再进入主页;`showVideoMenu == true` 时展示顶部 Tab,分类列表用 `mergeHomeTabsWithExtConfigItems` 把静态 Tab 放在最后,**该 Tab 的 Grid 数据源为 `ExtConfigRuntime.data.value?.items`**。第三方支付入口用 `allowThirdPartyPayment`;截屏策略用 `shouldPreventCapture` 或自行根据 `allowScreenshot` 调用宿主侧防护(框架不强制依赖 `screen_secure`)。 +首页逻辑建议(对齐 `app_client`):`await FrameworkAuthService.loginComplete` 后判断 `ExtConfigRuntime.commonInfoSucceeded.value == true` 再进入主页;`showVideoMenu == true` 时展示顶部 Tab,分类列表用 `mergeHomeTabsWithExtConfigItems` 把静态 Tab 放在最后,**该 Tab 的 Grid 数据源为 `ExtConfigRuntime.data.value?.items`**。第三方支付入口用 `allowThirdPartyPayment`;截屏策略用 `forbidScreenshot` 驱动宿主侧防护(如 `screen_secure`;框架不强制依赖)。 --- diff --git a/docs/new_app_config_template.md b/docs/new_app_config_template.md index 6032ba0..a20e3de 100644 --- a/docs/new_app_config_template.md +++ b/docs/new_app_config_template.md @@ -70,7 +70,7 @@ | 子节 | 说明 | |------|------| | `keys.showVideoMenu` | 字符串数组,如 `["go_run","need_wait"]`,依次为布尔字段候选键 | -| `keys.allowScreenshot` | 直接表示「允许截屏」的键,如 `["screen"]` | +| `keys.forbidScreenshot` | 线网为 `true` 时表示禁止截屏的键,如 `["screen"]`(兼容旧键名 `allowScreenshot`) | | `keys.blockScreenshot` | 为 `true` 时表示**禁止**截屏的键,如 `["safe_area"]` | | `keys.allowThirdPartyPayment` | 如 `["san_fang","lucky"]` | | `keys.privacyUrl` / `agreementUrl` / `items` | 隐私、协议 URL、items 数组所在键名 | diff --git a/lib/app.dart b/lib/app.dart index e976897..5d20b2d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -8,15 +10,50 @@ import 'core/theme/app_colors.dart'; import 'core/theme/app_theme.dart'; import 'features/shell/main_screen.dart'; -class App extends StatelessWidget { +class App extends StatefulWidget { const App({super.key, required this.title}); final String title; + @override + State createState() => _AppState(); +} + +class _AppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _reportFacebookActivateApp('first_frame'); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// 与 app_client 一致:关闭 FB 自动 App Events 时手动 [FacebookService.activateApp]。 + void _reportFacebookActivateApp(String reason) { + FacebookService.activateApp(); + if (kDebugMode) { + debugPrint('[App] Facebook activateApp ($reason)'); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _reportFacebookActivateApp('lifecycle_resumed'); + } + } + @override Widget build(BuildContext context) { return MaterialApp( - title: title, + title: widget.title, debugShowCheckedModeBanner: false, theme: buildFunyMeeTheme(), home: const MainScreen(), diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index 9ce3658..375f07d 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; @@ -6,6 +7,7 @@ import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:crypto/crypto.dart' show md5; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:screen_secure/screen_secure.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../user/user_state.dart'; @@ -58,6 +60,11 @@ class AppAuthCallbacks implements AuthServiceCallbacks { avatar: data.avatar, userName: data.userName, ); + unawaited( + AnalyticsEvents.trackRegisterIfNeeded( + firstRegister: data.firstRegister == true, + ), + ); } @override @@ -77,11 +84,77 @@ class AppAuthCallbacks implements AuthServiceCallbacks { class AuthService { static final _authCallbacks = AppAuthCallbacks(); + static var _screenSecureListenerBound = false; + + /// [ExtConfigRuntime.data] 初次为 `null` 时触发的 `_applyScreenSecure(null)` 与后续 + /// common_info 触发的 `_applyScreenSecure(…)` 均为异步:若前者后完成会错误地关闭防护,故用序号丢弃过期任务。 + static int _screenSecureApplyGeneration = 0; + static Future init() async { FrameworkAuthService.init(_authCallbacks); + _bindScreenSecureToExtConfig(); await FrameworkAuthService.start(); } + /// 与 [app_client] `AuthService.runWithNativeMediaPicker` 一致:系统相册/相机返回后若开启防截屏, + /// 部分机型会黑屏;选图前后临时关闭防护,结束后再按 [ExtConfigRuntime] 恢复。 + static Future runWithNativeMediaPicker(Future Function() action) async { + if (defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS) { + return await action(); + } + try { + await ScreenSecure.disableScreenshotBlock(); + await ScreenSecure.disableScreenRecordBlock(); + } on ScreenSecureException catch (e) { + debugPrint('[AuthService] native media picker: disable failed: ${e.message}'); + } + try { + return await action(); + } finally { + unawaited(_applyScreenSecure(ExtConfigRuntime.data.value)); + } + } + + static void _bindScreenSecureToExtConfig() { + if (_screenSecureListenerBound) return; + _screenSecureListenerBound = true; + void listener() { + unawaited(_applyScreenSecure(ExtConfigRuntime.data.value)); + } + + ExtConfigRuntime.data.addListener(listener); + // 勿在此处同步调用 listener():`data == null` 的 disable 若晚于 common_info 的 enable 完成,会把防截屏关掉。 + } + + /// [ExtConfigData]:`screen` / `safe_area` 等键在 [ExtConfigKeySchema] 下解析为 [ExtConfigData.forbidScreenshot]; + /// 为 `true` 时启用系统截屏/录屏防护(与 app_client `safe_area` 语义对齐)。 + static Future _applyScreenSecure(ExtConfigData? ext) async { + if (defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS) { + return; + } + final gen = ++_screenSecureApplyGeneration; + final block = ext?.forbidScreenshot == true; + try { + await ScreenSecure.init(screenshotBlock: false, screenRecordBlock: false); + if (gen != _screenSecureApplyGeneration) return; + if (block) { + await ScreenSecure.enableScreenshotBlock(); + await ScreenSecure.enableScreenRecordBlock(); + } else { + await ScreenSecure.disableScreenshotBlock(); + await ScreenSecure.disableScreenRecordBlock(); + } + if (gen != _screenSecureApplyGeneration) return; + if (kDebugMode && block) { + debugPrint('[AuthService] ScreenSecure: enabled (forbidScreenshot)'); + } + } on ScreenSecureException catch (e) { + debugPrint('[AuthService] ScreenSecure: ${e.message}'); + } + } + static Future get loginComplete => FrameworkAuthService.loginComplete; /// 登录流程是否已结束(含 fast_login + common_info 链路);用于遮罩,勿用 [loginComplete] 的 Future(首帧可能为 null)。 diff --git a/lib/features/generate/generate_screen.dart b/lib/features/generate/generate_screen.dart index 993eb5b..26a5b3c 100644 --- a/lib/features/generate/generate_screen.dart +++ b/lib/features/generate/generate_screen.dart @@ -9,6 +9,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/app_env.dart'; +import '../../core/auth/auth_service.dart'; import '../../core/open_purchase_store.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; @@ -142,7 +143,9 @@ class _GenerateScreenState extends State { if (!mounted) return; final source = await _showPickImageSourceSheet(context); if (source == null || !mounted) return; - final x = await _picker.pickImage(source: source, imageQuality: 92); + final x = await AuthService.runWithNativeMediaPicker( + () => _picker.pickImage(source: source, imageQuality: 92), + ); if (x == null || !mounted) return; setState(() { if (slot == 0) { @@ -619,7 +622,7 @@ class _GenerateScreenState extends State { ), const SizedBox(height: 12), Text( - 'Est. cost · $_estimatedCost credits', + 'cost · $_estimatedCost credits', textAlign: TextAlign.center, style: GoogleFonts.inter( fontSize: 13, @@ -627,22 +630,6 @@ class _GenerateScreenState extends State { color: PencilTheme.stone600, ), ), - const SizedBox(height: 8), - InkWell( - onTap: () => openPurchaseStore(context), - child: Text( - 'Balance · ${credits.toStringAsFixed(2)}', - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 12, - color: PencilTheme.inkSoft, - decoration: TextDecoration.underline, - decorationColor: PencilTheme.inkSoft.withValues( - alpha: 0.45, - ), - ), - ), - ), ], ], ), diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart index 2a4a617..5d3b144 100644 --- a/lib/features/profile/profile_screen.dart +++ b/lib/features/profile/profile_screen.dart @@ -73,7 +73,8 @@ class _ProfileScreenState extends State { Expanded( child: ListView( padding: EdgeInsets.only( - bottom: 28 + + bottom: + 28 + (widget.isRootTab ? PencilTheme.mainTabBottomChromeReserve(context) : 0), @@ -154,59 +155,7 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), child: _menuCard(context), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const PurchaseScreen(), - ), - ); - }, - child: Text( - 'Buy credits', - style: GoogleFonts.inter( - fontWeight: FontWeight.w600, - color: PencilTheme.underlineGold, - ), - ), - ), - TextButton( - onPressed: () async { - await UserAccountRefresh.fetchAndNotify( - app: currentBackendAppType(), - userId: UserState.userId.value, - onAccount: (a) { - if (a.credits != null) { - UserState.setCredits(a.credits!); - } - if (a.avatar != null) { - UserState.setAvatar(a.avatar!); - } - if (a.userName != null) { - UserState.setUserName(a.userName!); - } - }, - onFailure: (m) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(m))); - } - }, - ); - }, - child: Text( - 'Refresh', - style: GoogleFonts.inter( - fontWeight: FontWeight.w600, - color: PencilTheme.stone600, - ), - ), - ), - ], - ), + const SizedBox(height: 24), ], ), @@ -219,8 +168,11 @@ class _ProfileScreenState extends State { } Widget _avatarFallback() { - return Icon(Icons.person_rounded, - size: 44, color: PencilTheme.profileAvatarIcon); + return Icon( + Icons.person_rounded, + size: 44, + color: PencilTheme.profileAvatarIcon, + ); } String _formatCredits(int c) { @@ -235,17 +187,14 @@ class _ProfileScreenState extends State { required String? url, }) { if (url == null || url.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Link not configured')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Link not configured'))); return; } Navigator.of(context).push( MaterialPageRoute( - builder: (_) => AppWebViewScreen( - title: title, - initialUrl: url.trim(), - ), + builder: (_) => AppWebViewScreen(title: title, initialUrl: url.trim()), ), ); } @@ -288,17 +237,24 @@ class _ProfileScreenState extends State { _divider(), _row('Version', value: 'v$_version'), _divider(), - _row('Delete account', - danger: true, - trailing: Icons.chevron_right_rounded, - onTap: () => _delete(context)), + _row( + 'Delete account', + danger: true, + trailing: Icons.chevron_right_rounded, + onTap: () => _delete(context), + ), ], ), ); } - Widget _row(String title, - {IconData? trailing, String? value, bool danger = false, VoidCallback? onTap}) { + Widget _row( + String title, { + IconData? trailing, + String? value, + bool danger = false, + VoidCallback? onTap, + }) { return ListTile( onTap: onTap, title: Text( @@ -310,17 +266,21 @@ class _ProfileScreenState extends State { ), ), trailing: value != null - ? Text(value, + ? Text( + value, style: GoogleFonts.inter( - fontSize: 14, - color: const Color(0xFF78716C))) - : Icon(trailing, - color: danger ? const Color(0xFFFCA292) : const Color(0xFFA8A29E)), + fontSize: 14, + color: const Color(0xFF78716C), + ), + ) + : Icon( + trailing, + color: danger ? const Color(0xFFFCA292) : const Color(0xFFA8A29E), + ), ); } - Widget _divider() => - Container(height: 1, color: const Color(0xFFF5F5F4)); + Widget _divider() => Container(height: 1, color: const Color(0xFFF5F5F4)); Future _delete(BuildContext context) async { final ok = await showDeleteAccountConfirmationFlow(context); @@ -335,12 +295,16 @@ class _ProfileScreenState extends State { UserState.clear(); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Account deleted. Please restart the app and sign in again.')), + const SnackBar( + content: Text( + 'Account deleted. Please restart the app and sign in again.', + ), + ), ); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(res.msg)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(res.msg))); } } } diff --git a/lib/features/purchase/purchase_screen.dart b/lib/features/purchase/purchase_screen.dart index 58f6420..af4b806 100644 --- a/lib/features/purchase/purchase_screen.dart +++ b/lib/features/purchase/purchase_screen.dart @@ -212,6 +212,7 @@ class _PurchaseScreenState extends State { _paying = false; _selectedIndex = null; }); + AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -230,6 +231,7 @@ class _PurchaseScreenState extends State { _paying = false; _selectedIndex = null; }); + AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No payment methods available.')), ); @@ -256,7 +258,7 @@ class _PurchaseScreenState extends State { _selectedIndex = index; }); - final sink = _PurchaseSink( + final innerSink = _PurchaseSink( context: context, onRefresh: () { if (mounted) { @@ -272,6 +274,10 @@ class _PurchaseScreenState extends State { if (mounted) setState(() {}); }, ); + final sink = PaymentSettlementSinkWithAnalytics( + inner: innerSink, + analyticsProduct: item, + ); final outcome = await ThirdPartyCheckoutCoordinator.createOrder( userId: uid, @@ -288,6 +294,7 @@ class _PurchaseScreenState extends State { _paying = false; _selectedIndex = null; }); + AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(outcome.message ?? 'Create order failed')), ); @@ -319,6 +326,7 @@ class _PurchaseScreenState extends State { _paying = false; _selectedIndex = null; }); + AnalyticsEvents.trackPaymentFailed(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Invalid payment response.')), ); @@ -367,6 +375,8 @@ class _PurchaseScreenState extends State { return; } + AnalyticsEvents.trackTierSelection(item); + final ext = ExtConfigRuntime.data.value; if (_useThirdPartyFromExt(ext)) { await _onBuyThirdParty(item, index); @@ -389,7 +399,7 @@ class _PurchaseScreenState extends State { _selectedIndex = index; }); - final sink = _PurchaseSink( + final innerSink = _PurchaseSink( context: context, onRefresh: () { if (mounted) { @@ -403,6 +413,10 @@ class _PurchaseScreenState extends State { if (mounted) setState(() {}); }, ); + final sink = PaymentSettlementSinkWithAnalytics( + inner: innerSink, + analyticsProduct: item, + ); await NativeIapCoordinator.purchaseGooglePlay( sink: sink, diff --git a/lib/features/report/report_screen.dart b/lib/features/report/report_screen.dart index 9d35fda..8ad089e 100644 --- a/lib/features/report/report_screen.dart +++ b/lib/features/report/report_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; +import '../../core/auth/auth_service.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; import 'report_feedback_upload.dart'; @@ -39,9 +40,11 @@ class _ReportScreenState extends State { Future _pickImage() async { if (_submitting) return; - final x = await _picker.pickImage( - source: ImageSource.gallery, - imageQuality: 85, + final x = await AuthService.runWithNativeMediaPicker( + () => _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, + ), ); if (x == null || !mounted) return; setState(() => _imageFile = File(x.path)); diff --git a/pubspec.lock b/pubspec.lock index 68db174..ef392b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -743,6 +743,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + screen_secure: + dependency: "direct main" + description: + name: screen_secure + sha256: "9660d0a285f7e27d482333779153e3cb17c5fd929e15373e51abad63810df7b4" + url: "https://pub.dev" + source: hosted + version: "1.0.3" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8676f96..ecef28a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: package_info_plus: ^8.1.2 webview_flutter: ^4.13.1 gal: ^2.3.2 + screen_secure: ^1.0.3 dev_dependencies: flutter_test: