web_controller.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. import 'dart:io';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:get/get.dart';
  5. import 'package:device_info_plus/device_info_plus.dart';
  6. import 'package:package_info_plus/package_info_plus.dart';
  7. import 'package:flutter_inappwebview/flutter_inappwebview.dart';
  8. import 'package:url_launcher/url_launcher.dart';
  9. import '../../../controllers/core_controller.dart';
  10. import '../../../data/sp/ix_sp.dart';
  11. import '../../../constants/enums.dart' as enums;
  12. import '../../../../utils/log/logger.dart';
  13. import '../utils/js_bridge.dart';
  14. export 'package:flutter/services.dart' show rootBundle;
  15. class WebController extends GetxController {
  16. final TAG = 'WebController';
  17. var title = '';
  18. var url = '';
  19. InAppWebViewController? webViewController;
  20. final isLoading = true.obs;
  21. final loadingProgress = 0.0.obs;
  22. final canGoBack = false.obs; // 添加是否可以回退的观察变量
  23. final isFullScreen = false.obs; // 全面屏模式
  24. // 当前的状态栏样式(响应式)
  25. final currentStatusBarStyle = const SystemUiOverlayStyle(
  26. statusBarColor: Colors.transparent,
  27. statusBarIconBrightness: Brightness.light,
  28. statusBarBrightness: Brightness.light,
  29. ).obs;
  30. // final _externalSchemes = [
  31. // 'mailto:',
  32. // 'tel:',
  33. // 'sms:',
  34. // 'geo:',
  35. // 'intent:',
  36. // 'market:'
  37. // ];
  38. @override
  39. void onInit() {
  40. if (Get.arguments != null) {
  41. title = Get.arguments['title'] ?? '';
  42. url = Get.arguments['url'] ?? '';
  43. isFullScreen.value = Get.arguments['fullScreen'] ?? false;
  44. }
  45. loadUserAgent();
  46. _listenToVPNStatusChanges();
  47. super.onInit();
  48. }
  49. /// 加载本地 Assets HTML 文件
  50. Future<void> loadAssetHtml(String assetPath) async {
  51. try {
  52. final htmlContent = await rootBundle.loadString(assetPath);
  53. if (webViewController != null) {
  54. // iOS 需要一个有效的 baseUrl,使用 file:// 协议
  55. // 这样可以确保相对路径和本地资源正确加载
  56. final baseUrl = Platform.isIOS
  57. ? WebUri('file:///flutter_assets/')
  58. : WebUri('https://localhost/');
  59. await webViewController!.loadData(
  60. data: htmlContent,
  61. baseUrl: baseUrl,
  62. mimeType: 'text/html',
  63. encoding: 'utf-8',
  64. );
  65. log(TAG, '成功加载本地 HTML: $assetPath (baseUrl: $baseUrl)');
  66. }
  67. } catch (e) {
  68. log(TAG, '加载本地 HTML 失败: $e');
  69. }
  70. }
  71. Future<void> loadUserAgent() async {
  72. initWebView(await generateUserAgent());
  73. }
  74. Future<String> generateUserAgent() async {
  75. try {
  76. final deviceInfo = DeviceInfoPlugin();
  77. final packageInfo = await PackageInfo.fromPlatform();
  78. if (Platform.isIOS) {
  79. final iosInfo = await deviceInfo.iosInfo;
  80. // 使用类似 Safari 的 UA 格式
  81. return 'Mozilla/5.0 (iPhone; CPU iPhone OS ${iosInfo.systemVersion.replaceAll('.', '_')} like Mac OS X) '
  82. 'AppleWebKit/605.1.15 (KHTML, like Gecko) '
  83. 'Version/${packageInfo.version} Mobile/15E148 Safari/604.1';
  84. } else {
  85. final androidInfo = await deviceInfo.androidInfo;
  86. // 使用简化的 Android UA 格式,避免 WebView 标识
  87. return 'Mozilla/5.0 (Linux; Android ${androidInfo.version.release}; ${androidInfo.model}) '
  88. 'AppleWebKit/537.36 (KHTML, like Gecko) '
  89. 'Version/${packageInfo.version} Mobile Safari/537.36';
  90. }
  91. } catch (e) {
  92. // 如果获取设备信息失败,返回一个通用的安全 UA
  93. return 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) '
  94. 'AppleWebKit/605.1.15 (KHTML, like Gecko) '
  95. 'Version/16.6 Mobile/15E148 Safari/604.1';
  96. }
  97. }
  98. String? userAgent;
  99. void initWebView(String ua) {
  100. userAgent = ua;
  101. // InAppWebView 的初始化在 WebView 组件中完成
  102. // 这里只需要设置 URL
  103. if (url.isNotEmpty) {
  104. // URL 将在 WebView 组件中加载
  105. }
  106. }
  107. // 设置 WebViewController 的引用
  108. void setWebViewController(InAppWebViewController controller) {
  109. webViewController = controller;
  110. // 设置 User Agent
  111. if (userAgent != null) {
  112. webViewController?.setSettings(
  113. settings: InAppWebViewSettings(
  114. userAgent: userAgent!,
  115. javaScriptEnabled: true,
  116. useShouldOverrideUrlLoading: true,
  117. mediaPlaybackRequiresUserGesture: false,
  118. allowsInlineMediaPlayback: true,
  119. // iOS 特定设置
  120. allowsBackForwardNavigationGestures: true,
  121. suppressesIncrementalRendering: false,
  122. transparentBackground: false,
  123. isInspectable: true,
  124. ),
  125. );
  126. }
  127. }
  128. // 检查是否可以回退的方法
  129. Future<bool> checkCanGoBack() async {
  130. try {
  131. if (webViewController != null) {
  132. final canBack = await webViewController!.canGoBack();
  133. canGoBack.value = canBack;
  134. }
  135. } catch (e) {
  136. // 如果检查失败,默认设置为false
  137. canGoBack.value = false;
  138. }
  139. return canGoBack.value;
  140. }
  141. // 回退方法
  142. Future<void> goBack() async {
  143. if (canGoBack.value && webViewController != null) {
  144. await webViewController!.goBack();
  145. // 回退后重新检查状态
  146. checkCanGoBack();
  147. }
  148. }
  149. // 刷新方法
  150. Future<void> reload() async {
  151. if (webViewController != null) {
  152. await webViewController!.reload();
  153. }
  154. }
  155. String? parseIntentUrl(String url) {
  156. if (!url.startsWith("intent://")) return url;
  157. // 提取 scheme
  158. final schemeMatch = RegExp(r"scheme=([a-zA-Z0-9.+-]+);").firstMatch(url);
  159. final scheme = schemeMatch?.group(1) ?? "https";
  160. // 替换 intent:// -> scheme://
  161. var cleanUrl = url
  162. .replaceFirst("intent://", "$scheme://")
  163. .replaceAll(RegExp(r"#Intent;.*;end$"), "");
  164. return cleanUrl;
  165. }
  166. String? parseFallbackUrl(String url) {
  167. final match = RegExp(r"S\.browser_fallback_url=([^;]+);").firstMatch(url);
  168. return match != null ? Uri.decodeComponent(match.group(1)!) : null;
  169. }
  170. // ==================== JS Bridge 功能 ====================
  171. /// 设置 JavaScript Handlers
  172. void setupJavaScriptHandlers(InAppWebViewController controller) {
  173. // 设置状态栏颜色
  174. controller.addJavaScriptHandler(
  175. handlerName: JSBridgeConstants.setStatusBarColor,
  176. callback: (args) async {
  177. return await _handleSetStatusBarColor(args);
  178. },
  179. );
  180. // 退出 WebView
  181. controller.addJavaScriptHandler(
  182. handlerName: JSBridgeConstants.exitWebView,
  183. callback: (args) async {
  184. return await _handleExitWebView(args);
  185. },
  186. );
  187. // 连接 VPN
  188. controller.addJavaScriptHandler(
  189. handlerName: JSBridgeConstants.connectVPN,
  190. callback: (args) async {
  191. return await _handleConnectVPN(args);
  192. },
  193. );
  194. // 断开 VPN
  195. controller.addJavaScriptHandler(
  196. handlerName: JSBridgeConstants.disconnectVPN,
  197. callback: (args) async {
  198. return await _handleDisconnectVPN(args);
  199. },
  200. );
  201. // 打开指定 App
  202. controller.addJavaScriptHandler(
  203. handlerName: JSBridgeConstants.openApp,
  204. callback: (args) async {
  205. return await _handleOpenApp(args);
  206. },
  207. );
  208. // 获取 VPN 状态
  209. controller.addJavaScriptHandler(
  210. handlerName: JSBridgeConstants.getVPNStatus,
  211. callback: (args) async {
  212. return await _handleGetVPNStatus(args);
  213. },
  214. );
  215. log(TAG, 'JavaScript Handlers 已设置');
  216. }
  217. /// 注入原生属性到 JS
  218. Future<void> injectNativeAttrs() async {
  219. if (webViewController == null) return;
  220. try {
  221. // 获取状态栏和底部安全区域高度
  222. double statusBarHeight = 0;
  223. double bottomBarHeight = 0;
  224. try {
  225. statusBarHeight = Get.mediaQuery.padding.top;
  226. bottomBarHeight = Get.mediaQuery.padding.bottom;
  227. } catch (e) {
  228. log(TAG, '获取 MediaQuery 失败,使用默认值: $e');
  229. // 使用默认值
  230. statusBarHeight = Platform.isIOS ? 47 : 24;
  231. bottomBarHeight = Platform.isIOS ? 34 : 0;
  232. }
  233. // 获取 token
  234. final user = IXSP.getUser();
  235. final token = user?.accessToken ?? '';
  236. // 注入 nativeAttrs 对象
  237. final jsCode =
  238. '''
  239. (function() {
  240. // 创建 nativeAttrs 对象
  241. window.${JSBridgeConstants.nativeAttrs} = {
  242. statusBarHeight: $statusBarHeight,
  243. bottomBarHeight: $bottomBarHeight,
  244. token: '$token',
  245. platform: '${Platform.isIOS ? 'ios' : 'android'}',
  246. version: '${await _getAppVersion()}',
  247. };
  248. // 创建原生方法调用接口
  249. window.native = {
  250. // 设置状态栏模式(只支持 'dark' 或 'light')
  251. setStatusBarColor: function(mode) {
  252. return window.flutter_inappwebview.callHandler('${JSBridgeConstants.setStatusBarColor}', {color: mode});
  253. },
  254. // 退出 WebView
  255. exit: function() {
  256. return window.flutter_inappwebview.callHandler('${JSBridgeConstants.exitWebView}');
  257. },
  258. // 连接 VPN
  259. connectVPN: function(id, code) {
  260. return window.flutter_inappwebview.callHandler('${JSBridgeConstants.connectVPN}', {id: id, code: code});
  261. },
  262. // 断开 VPN
  263. disconnectVPN: function() {
  264. return window.flutter_inappwebview.callHandler('${JSBridgeConstants.disconnectVPN}');
  265. },
  266. // 打开指定 App
  267. openApp: function(packageName, scheme, extras) {
  268. return window.flutter_inappwebview.callHandler('${JSBridgeConstants.openApp}', {
  269. packageName: packageName,
  270. scheme: scheme,
  271. extras: extras
  272. });
  273. },
  274. // 获取 VPN 状态
  275. getVPNStatus: function() {
  276. return window.flutter_inappwebview.callHandler('${JSBridgeConstants.getVPNStatus}');
  277. }
  278. };
  279. // 创建事件回调接口
  280. window.nativeCallbacks = {
  281. onVPNStatusChanged: function(status) {
  282. console.log('VPN状态变化:', status);
  283. },
  284. onVPNConnected: function() {
  285. console.log('VPN已连接');
  286. },
  287. onVPNDisconnected: function() {
  288. console.log('VPN已断开');
  289. },
  290. onVPNConnecting: function() {
  291. console.log('VPN连接中');
  292. }
  293. };
  294. console.log('Native bridge initialized:', window.${JSBridgeConstants.nativeAttrs});
  295. })();
  296. ''';
  297. await webViewController!.evaluateJavascript(source: jsCode);
  298. log(TAG, 'Native Attrs 注入成功');
  299. } catch (e) {
  300. log(TAG, '注入 Native Attrs 失败: $e');
  301. }
  302. }
  303. /// 获取应用版本
  304. Future<String> _getAppVersion() async {
  305. try {
  306. final packageInfo = await PackageInfo.fromPlatform();
  307. return packageInfo.version;
  308. } catch (e) {
  309. return '1.0.0';
  310. }
  311. }
  312. // ==================== Handler 实现 ====================
  313. /// 处理设置状态栏模式(只支持 dark/light)
  314. Future<Map<String, dynamic>> _handleSetStatusBarColor(
  315. List<dynamic> args,
  316. ) async {
  317. try {
  318. if (args.isEmpty) {
  319. return JSBridgeResponse(success: false, error: '缺少模式参数').toJson();
  320. }
  321. final params = args[0] as Map<String, dynamic>;
  322. final mode = params['color'] as String?;
  323. if (mode == null || mode.isEmpty) {
  324. return JSBridgeResponse(success: false, error: '模式值不能为空').toJson();
  325. }
  326. // 只支持 'dark' 和 'light' 两种模式
  327. final Brightness iconBrightness;
  328. if (mode.toLowerCase() == 'dark') {
  329. // dark 模式 = 白色图标(用于深色背景)
  330. iconBrightness = Brightness.light;
  331. } else if (mode.toLowerCase() == 'light') {
  332. // light 模式 = 黑色图标(用于浅色背景)
  333. iconBrightness = Brightness.dark;
  334. } else {
  335. return JSBridgeResponse(
  336. success: false,
  337. error: '只支持 "dark" 或 "light" 模式',
  338. ).toJson();
  339. }
  340. // 创建新的状态栏样式(背景色保持透明)
  341. final newStyle = SystemUiOverlayStyle(
  342. statusBarColor: Colors.transparent, // 保持透明
  343. statusBarIconBrightness: iconBrightness, // 只改变图标颜色
  344. statusBarBrightness: iconBrightness,
  345. );
  346. // 更新响应式状态栏样式,触发 AnnotatedRegion 重新渲染
  347. currentStatusBarStyle.value = newStyle;
  348. log(TAG, '设置状态栏模式: $mode (图标亮度: $iconBrightness)');
  349. return JSBridgeResponse(success: true, data: {'mode': mode}).toJson();
  350. } catch (e) {
  351. log(TAG, '设置状态栏模式失败: $e');
  352. return JSBridgeResponse(success: false, error: e.toString()).toJson();
  353. }
  354. }
  355. /// 处理退出 WebView
  356. Future<Map<String, dynamic>> _handleExitWebView(List<dynamic> args) async {
  357. try {
  358. log(TAG, '退出 WebView');
  359. Get.back();
  360. return JSBridgeResponse(success: true).toJson();
  361. } catch (e) {
  362. log(TAG, '退出 WebView 失败: $e');
  363. return JSBridgeResponse(success: false, error: e.toString()).toJson();
  364. }
  365. }
  366. /// 处理连接 VPN
  367. Future<Map<String, dynamic>> _handleConnectVPN(List<dynamic> args) async {
  368. try {
  369. final params = args.isNotEmpty
  370. ? args[0] as Map<String, dynamic>
  371. : <String, dynamic>{};
  372. final vpnParams = VPNConnectParams.fromJson(params);
  373. log(TAG, '连接 VPN: id=${vpnParams.id}, code=${vpnParams.code}');
  374. // 如果提供了 id 和 code,保存为选中的位置
  375. if (vpnParams.id != null && vpnParams.code != null) {
  376. await IXSP.saveSelectedLocation({
  377. 'id': vpnParams.id,
  378. 'code': vpnParams.code,
  379. });
  380. }
  381. // 调用 CoreController 连接 VPN
  382. final coreController = Get.find<CoreController>();
  383. coreController.handleConnection();
  384. return JSBridgeResponse(
  385. success: true,
  386. data: {'id': vpnParams.id, 'code': vpnParams.code},
  387. ).toJson();
  388. } catch (e) {
  389. log(TAG, '连接 VPN 失败: $e');
  390. return JSBridgeResponse(success: false, error: e.toString()).toJson();
  391. }
  392. }
  393. /// 处理断开 VPN
  394. Future<Map<String, dynamic>> _handleDisconnectVPN(List<dynamic> args) async {
  395. try {
  396. log(TAG, '断开 VPN');
  397. // 调用 CoreController 断开 VPN
  398. final coreController = Get.find<CoreController>();
  399. if (coreController.state != enums.ConnectionState.disconnected) {
  400. coreController.handleConnection();
  401. }
  402. return JSBridgeResponse(success: true).toJson();
  403. } catch (e) {
  404. log(TAG, '断开 VPN 失败: $e');
  405. return JSBridgeResponse(success: false, error: e.toString()).toJson();
  406. }
  407. }
  408. /// 处理打开指定 App
  409. Future<Map<String, dynamic>> _handleOpenApp(List<dynamic> args) async {
  410. try {
  411. if (args.isEmpty) {
  412. return JSBridgeResponse(success: false, error: '缺少参数').toJson();
  413. }
  414. final params = args[0] as Map<String, dynamic>;
  415. final appParams = OpenAppParams.fromJson(params);
  416. log(TAG, '打开 App: ${appParams.packageName}');
  417. Uri? uri;
  418. // 优先使用 scheme
  419. if (appParams.scheme != null && appParams.scheme!.isNotEmpty) {
  420. uri = Uri.parse(appParams.scheme!);
  421. } else if (Platform.isAndroid) {
  422. // Android 使用 package name
  423. uri = Uri.parse('package:${appParams.packageName}');
  424. } else if (Platform.isIOS) {
  425. // iOS 需要使用 app 的 URL Scheme
  426. return JSBridgeResponse(
  427. success: false,
  428. error: 'iOS 需要提供 URL Scheme',
  429. ).toJson();
  430. }
  431. if (uri != null && await canLaunchUrl(uri)) {
  432. await launchUrl(uri, mode: LaunchMode.externalApplication);
  433. return JSBridgeResponse(
  434. success: true,
  435. data: {'packageName': appParams.packageName},
  436. ).toJson();
  437. } else {
  438. return JSBridgeResponse(success: false, error: '无法打开应用').toJson();
  439. }
  440. } catch (e) {
  441. log(TAG, '打开 App 失败: $e');
  442. return JSBridgeResponse(success: false, error: e.toString()).toJson();
  443. }
  444. }
  445. /// 处理获取 VPN 状态
  446. Future<Map<String, dynamic>> _handleGetVPNStatus(List<dynamic> args) async {
  447. try {
  448. final coreController = Get.find<CoreController>();
  449. final state = coreController.state;
  450. final timer = coreController.timer;
  451. String statusText;
  452. switch (state) {
  453. case enums.ConnectionState.connected:
  454. statusText = 'connected';
  455. break;
  456. case enums.ConnectionState.connecting:
  457. statusText = 'connecting';
  458. break;
  459. case enums.ConnectionState.disconnected:
  460. statusText = 'disconnected';
  461. break;
  462. default:
  463. statusText = 'unknown';
  464. }
  465. log(TAG, '获取 VPN 状态: $statusText');
  466. return JSBridgeResponse(
  467. success: true,
  468. data: {'status': statusText, 'timer': timer},
  469. ).toJson();
  470. } catch (e) {
  471. log(TAG, '获取 VPN 状态失败: $e');
  472. return JSBridgeResponse(success: false, error: e.toString()).toJson();
  473. }
  474. }
  475. // ==================== 监听 VPN 状态变化并通知 JS ====================
  476. // 保存 Worker 引用
  477. Worker? _vpnStatusWorker;
  478. @override
  479. void onClose() {
  480. // 取消 VPN 状态监听
  481. _vpnStatusWorker?.dispose();
  482. _vpnStatusWorker = null;
  483. super.onClose();
  484. }
  485. /// 监听 VPN 状态变化
  486. void _listenToVPNStatusChanges() {
  487. try {
  488. final coreController = Get.find<CoreController>();
  489. // 使用 ever 监听 CoreController 提供的状态流
  490. _vpnStatusWorker = ever(coreController.stateStream, (
  491. enums.ConnectionState state,
  492. ) {
  493. _notifyVPNStatusToJS(state);
  494. log(TAG, 'VPN 状态已变化并通知 JS: $state');
  495. });
  496. log(TAG, 'VPN 状态监听已启动');
  497. } catch (e) {
  498. log(TAG, '监听 VPN 状态变化失败: $e');
  499. }
  500. }
  501. /// 通知 JS VPN 状态变化
  502. void _notifyVPNStatusToJS(enums.ConnectionState state) async {
  503. if (webViewController == null) return;
  504. try {
  505. String statusText;
  506. String callbackName;
  507. switch (state) {
  508. case enums.ConnectionState.connected:
  509. statusText = 'connected';
  510. callbackName = 'onVPNConnected';
  511. break;
  512. case enums.ConnectionState.connecting:
  513. statusText = 'connecting';
  514. callbackName = 'onVPNConnecting';
  515. break;
  516. case enums.ConnectionState.disconnected:
  517. statusText = 'disconnected';
  518. callbackName = 'onVPNDisconnected';
  519. break;
  520. default:
  521. statusText = 'unknown';
  522. callbackName = 'onVPNStatusChanged';
  523. }
  524. // 调用 JS 回调
  525. final jsCode =
  526. '''
  527. (function() {
  528. try {
  529. // 调用通用状态变化回调
  530. if (window.nativeCallbacks && typeof window.nativeCallbacks.onVPNStatusChanged === 'function') {
  531. window.nativeCallbacks.onVPNStatusChanged('$statusText');
  532. }
  533. // 调用特定状态回调
  534. if (window.nativeCallbacks && typeof window.nativeCallbacks.$callbackName === 'function') {
  535. window.nativeCallbacks.$callbackName();
  536. }
  537. // 触发自定义事件
  538. window.dispatchEvent(new CustomEvent('vpnStatusChanged', {
  539. detail: { status: '$statusText' }
  540. }));
  541. } catch (e) {
  542. console.error('VPN status callback error:', e);
  543. }
  544. })();
  545. ''';
  546. await webViewController!.evaluateJavascript(source: jsCode);
  547. log(TAG, '通知 JS VPN 状态变化: $statusText');
  548. } catch (e) {
  549. log(TAG, '通知 JS VPN 状态变化失败: $e');
  550. }
  551. }
  552. }