diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b8a8db0..7609503 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 8C4E1A022F9A4B1000C636F3 /* WirelessDataPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -49,6 +50,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WirelessDataPrompt.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -122,6 +124,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -282,6 +285,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 8C4E1A022F9A4B1000C636F3 /* WirelessDataPrompt.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b52a229..88ec5d6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -37,6 +37,7 @@ final class RunnerFlutterViewController: FlutterViewController { @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private let wirelessDataPrompt = WirelessDataPrompt() private let thumbnailQueue = DispatchQueue( label: "com.stormg.lumadrama.thumbnail", qos: .userInitiated @@ -52,6 +53,7 @@ final class RunnerFlutterViewController: FlutterViewController { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + registerWirelessDataChannel(engineBridge) let channel = FlutterMethodChannel( name: "com.stormg.lumadrama/share", binaryMessenger: engineBridge.applicationRegistrar.messenger() @@ -132,4 +134,22 @@ final class RunnerFlutterViewController: FlutterViewController { } } } + + private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) { + let channel = FlutterMethodChannel( + name: WirelessDataPrompt.channelName, + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == WirelessDataPrompt.awaitDecisionMethod else { + result(FlutterMethodNotImplemented) + return + } + guard let self else { + result(nil) + return + } + self.wirelessDataPrompt.awaitDecision(result) + } + } } diff --git a/ios/Runner/WirelessDataPrompt.swift b/ios/Runner/WirelessDataPrompt.swift new file mode 100644 index 0000000..63e3610 --- /dev/null +++ b/ios/Runner/WirelessDataPrompt.swift @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import CoreTelephony +import Flutter +import Foundation + +/// 监听系统无线数据选择,供 Flutter 在首个接口请求前等待。 +final class WirelessDataPrompt { + static let channelName = "com.stormg.lumadrama/wireless-data" + static let awaitDecisionMethod = "awaitDecision" + + private let cellularData = CTCellularData() + private let lock = NSLock() + private var pending: [FlutterResult] = [] + private var decided = false + + init() { + cellularData.cellularDataRestrictionDidUpdateNotifier = { [weak self] state in + DispatchQueue.main.async { + self?.completeIfDecided(state) + } + } + } + + /// 状态未选择时先挂起;用户点选后完成全部等待。 + func awaitDecision(_ result: @escaping FlutterResult) { + lock.lock() + if decided { + lock.unlock() + DispatchQueue.main.async { + result(nil) + } + return + } + pending.append(result) + lock.unlock() + } + + private func completeIfDecided(_ state: CTCellularDataRestrictedState) { + if state == .restrictedStateUnknown { + return + } + lock.lock() + if decided { + lock.unlock() + return + } + decided = true + let waiters = pending + pending.removeAll() + lock.unlock() + waiters.forEach { $0(nil) } + } +} diff --git a/lib/core/platform/wireless_data_prompt.dart b/lib/core/platform/wireless_data_prompt.dart new file mode 100644 index 0000000..6a509a0 --- /dev/null +++ b/lib/core/platform/wireless_data_prompt.dart @@ -0,0 +1,62 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// iOS 首次启动等待系统无线数据选择,再发首个接口请求。 +abstract final class WirelessDataPrompt { + const WirelessDataPrompt._(); + + /// 系统选择超过此时长仍未回调时继续启动。 + static const Duration decisionTimeout = Duration(seconds: 15); + + /// 与 iOS `WirelessDataPrompt.channelName` 保持一致。 + static const String channelName = 'com.stormg.lumadrama/wireless-data'; + + static const String _awaitDecisionMethod = 'awaitDecision'; + + static const MethodChannel _channel = MethodChannel(channelName); + + static Future? _pending; + + /// 非 iOS 立即返回。iOS 只等待一次,失败或超时后放行。 + static Future awaitDecision({ + required bool enabled, + Future Function()? ask, + Duration? timeout, + }) { + if (!enabled) return Future.value(); + return _pending ??= _askOnce(ask, timeout ?? decisionTimeout); + } + + /// 测试隔离用,清掉已完成的等待。 + @visibleForTesting + static void resetForTest() { + _pending = null; + } + + static Future _askOnce( + Future Function()? ask, + Duration timeout, + ) async { + try { + await (ask ?? _invokeChannel).call().timeout(timeout); + } on TimeoutException { + _log('decision timed out'); + } catch (error) { + _log('decision skipped: $error'); + } + } + + static Future _invokeChannel() { + return _channel.invokeMethod(_awaitDecisionMethod); + } + + static void _log(String message) { + if (kDebugMode) { + debugPrint('[wireless-data] $message'); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index a87b4f6..933dcc9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,17 +2,21 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'app.dart'; import 'core/config/app_config.dart'; import 'core/config/server_endpoint.dart'; +import 'core/platform/wireless_data_prompt.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await ServerEndpoint.load(); - AppConfig.ready = AppConfig.probeAndApplyBaseUrl(); + AppConfig.ready = WirelessDataPrompt.awaitDecision( + enabled: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS, + ).then((_) => AppConfig.probeAndApplyBaseUrl()); runApp(const LumaDramaApp()); unawaited( SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), diff --git a/test/platform/wireless_data_prompt_test.dart b/test/platform/wireless_data_prompt_test.dart new file mode 100644 index 0000000..bc036eb --- /dev/null +++ b/test/platform/wireless_data_prompt_test.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/platform/wireless_data_prompt.dart'; + +void main() { + setUp(WirelessDataPrompt.resetForTest); + tearDown(WirelessDataPrompt.resetForTest); + + test('should_return_immediately_when_disabled', () async { + var calls = 0; + + await WirelessDataPrompt.awaitDecision( + enabled: false, + ask: () async { + calls++; + }, + ); + + expect(calls, 0); + }); + + test('should_ask_once_when_enabled', () async { + var calls = 0; + + Future ask() async { + calls++; + } + + await WirelessDataPrompt.awaitDecision(enabled: true, ask: ask); + await WirelessDataPrompt.awaitDecision(enabled: true, ask: ask); + + expect(calls, 1); + }); + + test('should_continue_when_decision_times_out', () async { + await WirelessDataPrompt.awaitDecision( + enabled: true, + ask: () => Completer().future, + timeout: const Duration(milliseconds: 20), + ); + }); + + test('should_continue_when_ask_fails', () async { + await WirelessDataPrompt.awaitDecision( + enabled: true, + ask: () async { + throw StateError('channel missing'); + }, + ); + }); +}