diff --git a/Kiosk/android/app/src/main/AndroidManifest.xml b/Kiosk/android/app/src/main/AndroidManifest.xml index 8748df4..bb1f914 100644 --- a/Kiosk/android/app/src/main/AndroidManifest.xml +++ b/Kiosk/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ + + + + result.notImplemented() } } + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, NETWORK_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "getHotspotEnabled" -> result.success(localHotspotReservation != null || isSystemHotspotEnabled()) + "getHotspotInfo" -> { + val systemEnabled = isSystemHotspotEnabled() + val systemConfig = if (systemEnabled && localHotspotReservation == null) getSystemHotspotConfig() else null + result.success( + mapOf( + "enabled" to (localHotspotReservation != null || systemEnabled), + "mode" to (if (localHotspotReservation != null) "local" else if (systemEnabled) "system" else null), + "ssid" to + (if (localHotspotReservation != null) { + localHotspotSsid + } else { + systemConfig?.first + }), + "password" to + (if (localHotspotReservation != null) { + localHotspotPassword + } else { + systemConfig?.second + }), + ), + ) + } + "getHotspotLastError" -> { + result.success( + mapOf( + "code" to lastHotspotErrorCode, + "message" to lastHotspotErrorMessage, + ), + ) + } + "setHotspotEnabled" -> { + val enabled = call.argument("enabled") == true + lastHotspotErrorCode = null + lastHotspotErrorMessage = null + if (!enabled) { + localHotspotReservation?.close() + localHotspotReservation = null + localHotspotSsid = null + localHotspotPassword = null + if (isSystemHotspotEnabled()) { + val ok = disableSystemHotspot() + result.success(ok) + return@setMethodCallHandler + } else { + result.success(true) + return@setMethodCallHandler + } + } + + if (isSystemHotspotEnabled()) { + result.success(true) + return@setMethodCallHandler + } + + if (localHotspotReservation != null) { + result.success(true) + return@setMethodCallHandler + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + lastHotspotErrorMessage = "unsupported_sdk" + result.success(false) + return@setMethodCallHandler + } + + try { + val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + if (!wifiManager.isWifiEnabled) { + try { + @Suppress("DEPRECATION") + wifiManager.isWifiEnabled = true + } catch (_: Exception) { + } + } + + val locationManager = applicationContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val locationEnabled = + try { + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || + locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } catch (_: Exception) { + false + } + if (!locationEnabled) { + lastHotspotErrorMessage = "location_disabled" + result.success(false) + return@setMethodCallHandler + } + + wifiManager.startLocalOnlyHotspot( + object : WifiManager.LocalOnlyHotspotCallback() { + override fun onStarted(reservation: WifiManager.LocalOnlyHotspotReservation) { + localHotspotReservation = reservation + val config = reservation.wifiConfiguration + localHotspotSsid = config?.SSID + localHotspotPassword = config?.preSharedKey + result.success(true) + } + + override fun onStopped() { + localHotspotReservation?.close() + localHotspotReservation = null + localHotspotSsid = null + localHotspotPassword = null + } + + override fun onFailed(reason: Int) { + localHotspotReservation?.close() + localHotspotReservation = null + localHotspotSsid = null + localHotspotPassword = null + lastHotspotErrorCode = reason + lastHotspotErrorMessage = + when (reason) { + WifiManager.LocalOnlyHotspotCallback.ERROR_NO_CHANNEL -> "no_channel" + WifiManager.LocalOnlyHotspotCallback.ERROR_TETHERING_DISALLOWED -> "tethering_disallowed" + WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE -> "incompatible_mode" + else -> "failed_$reason" + } + result.success(false) + } + }, + Handler(Looper.getMainLooper()), + ) + } catch (_: Exception) { + lastHotspotErrorMessage = "exception" + result.success(false) + } + } + "openHotspotSettings" -> { + try { + startActivity(Intent("android.settings.TETHER_SETTINGS").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: Exception) { + startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + result.success(true) + } + "openLocationSettings" -> { + try { + startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: Exception) { + } + result.success(true) + } + "getMobileDataEnabled" -> { + val enabled = try { + Settings.Global.getInt(contentResolver, "mobile_data", 0) == 1 + } catch (_: Exception) { + false + } + result.success(enabled) + } + "setMobileDataEnabled" -> { + val enabled = call.argument("enabled") == true + val ok = try { + Settings.Global.putInt(contentResolver, "mobile_data", if (enabled) 1 else 0) + } catch (_: Exception) { + false + } + result.success(ok) + } + "openInternetSettings" -> { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startActivity(Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } else { + startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + } catch (_: Exception) { + } + result.success(true) + } + else -> result.notImplemented() + } + } } override fun onCreate(savedInstanceState: android.os.Bundle?) { @@ -169,6 +358,12 @@ class MainActivity : FlutterActivity() { } } receiver = null + localHotspotReservation?.close() + localHotspotReservation = null + localHotspotSsid = null + localHotspotPassword = null + lastHotspotErrorCode = null + lastHotspotErrorMessage = null super.onDestroy() } @@ -177,6 +372,56 @@ class MainActivity : FlutterActivity() { return enabled.contains(packageName) } + private fun isSystemHotspotEnabled(): Boolean { + return try { + val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + try { + val m = wifiManager.javaClass.getDeclaredMethod("isWifiApEnabled") + m.isAccessible = true + (m.invoke(wifiManager) as? Boolean) == true + } catch (_: Exception) { + val m = wifiManager.javaClass.getDeclaredMethod("getWifiApState") + m.isAccessible = true + val state = (m.invoke(wifiManager) as? Int) ?: return false + state == 13 || state == 12 + } + } catch (_: Exception) { + false + } + } + + private fun getSystemHotspotConfig(): kotlin.Pair? { + return try { + val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val m = wifiManager.javaClass.getDeclaredMethod("getWifiApConfiguration") + m.isAccessible = true + val config = m.invoke(wifiManager) as? WifiConfiguration ?: return null + kotlin.Pair(config.SSID, config.preSharedKey) + } catch (_: Exception) { + null + } + } + + private fun disableSystemHotspot(): Boolean { + return try { + val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + try { + val m = wifiManager.javaClass.getDeclaredMethod("setWifiApEnabled", WifiConfiguration::class.java, Boolean::class.java) + m.isAccessible = true + m.invoke(wifiManager, null, false) + true + } catch (_: Exception) { + val m = wifiManager.javaClass.getDeclaredMethod("stopSoftAp") + m.isAccessible = true + m.invoke(wifiManager) + true + } + } catch (_: Exception) { + lastHotspotErrorMessage = "system_disable_blocked" + false + } + } + private fun getAlipayNotificationState(): Map { val prefs = getSharedPreferences(SecgoNotificationListenerService.PREFS_NAME, Context.MODE_PRIVATE) val hasAlipay = prefs.getBoolean(SecgoNotificationListenerService.KEY_HAS_ALIPAY, false) @@ -271,6 +516,7 @@ class MainActivity : FlutterActivity() { companion object { private const val METHOD_CHANNEL = "com.secgo.kiosk/notification_listener" private const val EVENTS_CHANNEL = "com.secgo.kiosk/notifications" + private const val NETWORK_CHANNEL = "com.secgo.kiosk/network" @Volatile private var eventSink: EventChannel.EventSink? = null diff --git a/Kiosk/lib/l10n/app_en.arb b/Kiosk/lib/l10n/app_en.arb index 2d07b12..9718f51 100644 --- a/Kiosk/lib/l10n/app_en.arb +++ b/Kiosk/lib/l10n/app_en.arb @@ -70,6 +70,19 @@ "categoryAll": "All", "categoryUncategorized": "Uncategorized", "searchProducts": "Search products", + "networkSettings": "Network", + "hotspot": "Hotspot", + "hotspotHint": "Enable hotspot for pairing", + "ssidLabel": "SSID", + "passwordLabel": "Password", + "locationPermissionRequired": "Location permission is required to enable hotspot.", + "locationServiceRequired": "Please turn on Location service to enable hotspot.", + "hotspotFailedWithReason": "Hotspot failed: {reason}", + "hotspotEnabledInSystemSettings": "Hotspot is already enabled in system settings.", + "hotspotChangeInSystemSettings": "Change hotspot in system settings.", + "mobileData": "Mobile data", + "mobileDataHint": "Toggle mobile data (may be restricted)", + "networkToggleFailed": "Action failed. Opening system settings…", "clear": "Clear", "save": "Save" } diff --git a/Kiosk/lib/l10n/app_localizations.dart b/Kiosk/lib/l10n/app_localizations.dart index 68031a9..785b572 100644 --- a/Kiosk/lib/l10n/app_localizations.dart +++ b/Kiosk/lib/l10n/app_localizations.dart @@ -524,6 +524,84 @@ abstract class AppLocalizations { /// **'Search products'** String get searchProducts; + /// No description provided for @networkSettings. + /// + /// In en, this message translates to: + /// **'Network'** + String get networkSettings; + + /// No description provided for @hotspot. + /// + /// In en, this message translates to: + /// **'Hotspot'** + String get hotspot; + + /// No description provided for @hotspotHint. + /// + /// In en, this message translates to: + /// **'Enable hotspot for pairing'** + String get hotspotHint; + + /// No description provided for @ssidLabel. + /// + /// In en, this message translates to: + /// **'SSID'** + String get ssidLabel; + + /// No description provided for @passwordLabel. + /// + /// In en, this message translates to: + /// **'Password'** + String get passwordLabel; + + /// No description provided for @locationPermissionRequired. + /// + /// In en, this message translates to: + /// **'Location permission is required to enable hotspot.'** + String get locationPermissionRequired; + + /// No description provided for @locationServiceRequired. + /// + /// In en, this message translates to: + /// **'Please turn on Location service to enable hotspot.'** + String get locationServiceRequired; + + /// No description provided for @hotspotFailedWithReason. + /// + /// In en, this message translates to: + /// **'Hotspot failed: {reason}'** + String hotspotFailedWithReason(Object reason); + + /// No description provided for @hotspotEnabledInSystemSettings. + /// + /// In en, this message translates to: + /// **'Hotspot is already enabled in system settings.'** + String get hotspotEnabledInSystemSettings; + + /// No description provided for @hotspotChangeInSystemSettings. + /// + /// In en, this message translates to: + /// **'Change hotspot in system settings.'** + String get hotspotChangeInSystemSettings; + + /// No description provided for @mobileData. + /// + /// In en, this message translates to: + /// **'Mobile data'** + String get mobileData; + + /// No description provided for @mobileDataHint. + /// + /// In en, this message translates to: + /// **'Toggle mobile data (may be restricted)'** + String get mobileDataHint; + + /// No description provided for @networkToggleFailed. + /// + /// In en, this message translates to: + /// **'Action failed. Opening system settings…'** + String get networkToggleFailed; + /// No description provided for @clear. /// /// In en, this message translates to: diff --git a/Kiosk/lib/l10n/app_localizations_en.dart b/Kiosk/lib/l10n/app_localizations_en.dart index 77993b6..860652e 100644 --- a/Kiosk/lib/l10n/app_localizations_en.dart +++ b/Kiosk/lib/l10n/app_localizations_en.dart @@ -247,6 +247,51 @@ class AppLocalizationsEn extends AppLocalizations { @override String get searchProducts => 'Search products'; + @override + String get networkSettings => 'Network'; + + @override + String get hotspot => 'Hotspot'; + + @override + String get hotspotHint => 'Enable hotspot for pairing'; + + @override + String get ssidLabel => 'SSID'; + + @override + String get passwordLabel => 'Password'; + + @override + String get locationPermissionRequired => + 'Location permission is required to enable hotspot.'; + + @override + String get locationServiceRequired => + 'Please turn on Location service to enable hotspot.'; + + @override + String hotspotFailedWithReason(Object reason) { + return 'Hotspot failed: $reason'; + } + + @override + String get hotspotEnabledInSystemSettings => + 'Hotspot is already enabled in system settings.'; + + @override + String get hotspotChangeInSystemSettings => + 'Change hotspot in system settings.'; + + @override + String get mobileData => 'Mobile data'; + + @override + String get mobileDataHint => 'Toggle mobile data (may be restricted)'; + + @override + String get networkToggleFailed => 'Action failed. Opening system settings…'; + @override String get clear => 'Clear'; diff --git a/Kiosk/lib/l10n/app_localizations_zh.dart b/Kiosk/lib/l10n/app_localizations_zh.dart index 6bce3fb..67d617f 100644 --- a/Kiosk/lib/l10n/app_localizations_zh.dart +++ b/Kiosk/lib/l10n/app_localizations_zh.dart @@ -243,6 +243,47 @@ class AppLocalizationsZh extends AppLocalizations { @override String get searchProducts => '搜索商品'; + @override + String get networkSettings => '网络设置'; + + @override + String get hotspot => '热点'; + + @override + String get hotspotHint => '开启热点用于配对'; + + @override + String get ssidLabel => '名称'; + + @override + String get passwordLabel => '密码'; + + @override + String get locationPermissionRequired => '开启热点需要定位权限。'; + + @override + String get locationServiceRequired => '请开启定位服务后再打开热点。'; + + @override + String hotspotFailedWithReason(Object reason) { + return '热点开启失败:$reason'; + } + + @override + String get hotspotEnabledInSystemSettings => '系统热点已开启。'; + + @override + String get hotspotChangeInSystemSettings => '请在系统设置中修改热点。'; + + @override + String get mobileData => '移动数据'; + + @override + String get mobileDataHint => '切换移动数据(可能受系统限制)'; + + @override + String get networkToggleFailed => '操作失败,正在打开系统设置…'; + @override String get clear => '清除'; diff --git a/Kiosk/lib/l10n/app_zh.arb b/Kiosk/lib/l10n/app_zh.arb index 53167a8..54b7220 100644 --- a/Kiosk/lib/l10n/app_zh.arb +++ b/Kiosk/lib/l10n/app_zh.arb @@ -70,6 +70,19 @@ "categoryAll": "全部", "categoryUncategorized": "未分类", "searchProducts": "搜索商品", + "networkSettings": "网络设置", + "hotspot": "热点", + "hotspotHint": "开启热点用于配对", + "ssidLabel": "名称", + "passwordLabel": "密码", + "locationPermissionRequired": "开启热点需要定位权限。", + "locationServiceRequired": "请开启定位服务后再打开热点。", + "hotspotFailedWithReason": "热点开启失败:{reason}", + "hotspotEnabledInSystemSettings": "系统热点已开启。", + "hotspotChangeInSystemSettings": "请在系统设置中修改热点。", + "mobileData": "移动数据", + "mobileDataHint": "切换移动数据(可能受系统限制)", + "networkToggleFailed": "操作失败,正在打开系统设置…", "clear": "清除", "save": "保存" } diff --git a/Kiosk/lib/screens/settings_screen.dart b/Kiosk/lib/screens/settings_screen.dart index a051813..ffbc109 100644 --- a/Kiosk/lib/screens/settings_screen.dart +++ b/Kiosk/lib/screens/settings_screen.dart @@ -1,12 +1,16 @@ import 'dart:convert'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:kiosk/services/server/kiosk_server.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:kiosk/l10n/app_localizations.dart'; import 'package:kiosk/services/android_launcher_service.dart'; +import 'package:kiosk/services/android_network_service.dart'; import 'package:kiosk/services/settings_service.dart'; import 'package:kiosk/services/restore_notifier.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -15,21 +19,32 @@ class SettingsScreen extends StatefulWidget { State createState() => _SettingsScreenState(); } -class _SettingsScreenState extends State { +class _SettingsScreenState extends State with WidgetsBindingObserver { late final KioskServerService _serverService; final TextEditingController _pinController = TextEditingController(); final SettingsService _settingsService = SettingsService(); // Add SettingsService final AndroidLauncherService _launcherService = AndroidLauncherService(); + final AndroidNetworkService _networkService = AndroidNetworkService(); bool _isServerRunning = false; bool _isLoading = false; bool _showRestoreComplete = false; String? _qrData; + String? _deviceId; String? _homeAppPackage; String? _homeAppLabel; + bool _hotspotEnabled = false; + bool _mobileDataEnabled = false; + String? _hotspotSsid; + String? _hotspotPassword; + String? _hotspotMode; + bool _networkBusy = false; + StreamSubscription>? _connectivitySub; + Timer? _networkDebounce; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _serverService = KioskServerService(onRestoreComplete: _onRestoreComplete); _homeAppPackage = _settingsService.getHomeAppPackage(); _homeAppLabel = _settingsService.getHomeAppLabel(); @@ -42,15 +57,32 @@ class _SettingsScreenState extends State { _startServer(); }); } + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadNetworkState(); + }); + + _connectivitySub = Connectivity().onConnectivityChanged.listen((_) { + _onNetworkChanged(); + }); } @override void dispose() { + _networkDebounce?.cancel(); + _connectivitySub?.cancel(); + WidgetsBinding.instance.removeObserver(this); _serverService.stopServer(); _pinController.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _onNetworkChanged(); + } + } + Future _confirmPin() async { final l10n = AppLocalizations.of(context)!; final expected = _settingsService.getPin() ?? _pinController.text.trim(); @@ -262,41 +294,254 @@ class _SettingsScreenState extends State { setState(() => _showRestoreComplete = false); } - Future _startServer() async { + Future _loadNetworkState() async { + try { + final hotspotInfo = await _networkService.getHotspotInfo(); + final mobile = await _networkService.getMobileDataEnabled(); + if (!mounted) return; + setState(() { + _hotspotEnabled = hotspotInfo.enabled; + _hotspotMode = hotspotInfo.mode; + _hotspotSsid = hotspotInfo.ssid; + _hotspotPassword = hotspotInfo.password; + _mobileDataEnabled = mobile; + }); + } catch (_) { + } + } + + void _onNetworkChanged() { + _networkDebounce?.cancel(); + _networkDebounce = Timer(const Duration(milliseconds: 600), () async { + if (!mounted) return; + await _loadNetworkState(); + await _refreshServerStatus(); + }); + } + + void _updateQrData() { + final ip = _serverService.ipAddress; + final pin = _pinController.text; + if (ip == null || pin.length < 4) { + _qrData = null; + return; + } + _qrData = jsonEncode({ + 'ip': ip, + 'port': _serverService.port, + 'pin': pin, + 'deviceId': _deviceId, + }); + } + + Future _refreshServerStatus() async { + if (_isLoading) return; + if (_serverService.isRunning) { + await _serverService.refreshIpAddress(); + if (!mounted) return; + setState(() { + _isServerRunning = _serverService.ipAddress != null; + _updateQrData(); + }); + return; + } + + await _serverService.refreshIpAddress(); + if (!mounted) return; + if (_serverService.ipAddress == null) { + setState(() { + _isServerRunning = false; + _qrData = null; + }); + return; + } + + if (_pinController.text.length >= 4) { + await _startServer(silent: true); + } + } + + Future _setHotspot(bool enabled) async { + final l10n = AppLocalizations.of(context)!; + if (_hotspotMode == 'system') { + setState(() => _networkBusy = true); + try { + final ok = await _networkService.setHotspotEnabled(enabled); + if (!ok) { + await _networkService.openHotspotSettings(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.hotspotChangeInSystemSettings)), + ); + } + } + } catch (_) { + try { + await _networkService.openHotspotSettings(); + } catch (_) { + } + } finally { + await _loadNetworkState(); + await _refreshServerStatus(); + if (mounted) setState(() => _networkBusy = false); + } + return; + } + if (enabled) { + final status = await Permission.location.status; + if (!status.isGranted) { + final next = await Permission.location.request(); + if (!next.isGranted) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.locationPermissionRequired)), + ); + } + return; + } + } + } + setState(() => _networkBusy = true); + try { + final ok = await _networkService.setHotspotEnabled(enabled); + if (!ok) { + final err = await _networkService.getHotspotLastError(); + final message = (err['message'] ?? '').toString(); + if (message == 'location_disabled') { + await _networkService.openLocationSettings(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.locationServiceRequired)), + ); + } + return; + } + await _networkService.openHotspotSettings(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message.isEmpty ? l10n.networkToggleFailed : l10n.hotspotFailedWithReason(message), + ), + ), + ); + } + } + } catch (_) { + try { + await _networkService.openHotspotSettings(); + } catch (_) { + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.networkToggleFailed)), + ); + } + } finally { + await _loadNetworkState(); + await _refreshServerStatus(); + if (mounted) setState(() => _networkBusy = false); + } + } + + Future _setMobileData(bool enabled) async { + final l10n = AppLocalizations.of(context)!; + setState(() => _networkBusy = true); + try { + final ok = await _networkService.setMobileDataEnabled(enabled); + if (!ok) { + await _networkService.openInternetSettings(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.networkToggleFailed)), + ); + } + } + } catch (_) { + try { + await _networkService.openInternetSettings(); + } catch (_) { + } + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.networkToggleFailed)), + ); + } + } finally { + await _loadNetworkState(); + await _refreshServerStatus(); + if (mounted) setState(() => _networkBusy = false); + } + } + + Widget _buildNetworkSection() { final l10n = AppLocalizations.of(context)!; + final hotspotSubtitle = _hotspotMode == 'system' + ? (_hotspotSsid != null + ? '${l10n.hotspotEnabledInSystemSettings}\n${l10n.ssidLabel}: ${_hotspotSsid ?? '-'}\n${l10n.passwordLabel}: ${_hotspotPassword ?? '-'}' + : '${l10n.hotspotEnabledInSystemSettings}\n${l10n.hotspotChangeInSystemSettings}') + : (_hotspotEnabled && _hotspotSsid != null + ? '${l10n.hotspotHint}\n${l10n.ssidLabel}: ${_hotspotSsid ?? '-'}\n${l10n.passwordLabel}: ${_hotspotPassword ?? '-'}' + : l10n.hotspotHint); + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.wifi_tethering_outlined), + title: Text(l10n.networkSettings), + ), + const Divider(height: 1), + SwitchListTile( + value: _hotspotEnabled, + onChanged: _networkBusy ? null : _setHotspot, + title: Text(l10n.hotspot), + subtitle: Text(hotspotSubtitle), + ), + const Divider(height: 1), + SwitchListTile( + value: _mobileDataEnabled, + onChanged: _networkBusy ? null : _setMobileData, + title: Text(l10n.mobileData), + subtitle: Text(l10n.mobileDataHint), + ), + ], + ), + ); + } + + Future _startServer({bool silent = false}) async { + final l10n = AppLocalizations.of(context)!; + if (_isLoading) return; if (_pinController.text.length < 4) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.pinLength)), - ); + if (!silent) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.pinLength)), + ); + } return; } - setState(() { - _isLoading = true; - }); + if (!silent) { + setState(() { + _isLoading = true; + }); + } - final deviceId = await _settingsService.getOrCreateDeviceId(); - await _serverService.startServer(_pinController.text, deviceId: deviceId); + _deviceId ??= await _settingsService.getOrCreateDeviceId(); + await _serverService.startServer(_pinController.text, deviceId: _deviceId); if (mounted) { setState(() { _isLoading = false; - if (_serverService.ipAddress != null) { - _isServerRunning = true; - // QR Data format: {"ip": "192.168.x.x", "port": 8081, "pin": "1234"} - _qrData = jsonEncode({ - 'ip': _serverService.ipAddress, - 'port': _serverService.port, - 'pin': _pinController.text, - 'deviceId': deviceId, - }); - } else { - // Optional: Show error if IP is null (e.g. no wifi) - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.serverNoIp)), - ); - } + _isServerRunning = _serverService.ipAddress != null; + _updateQrData(); }); + if (!silent && _serverService.ipAddress == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.serverNoIp)), + ); + } } } @@ -313,9 +558,10 @@ class _SettingsScreenState extends State { child: _isLoading ? const CircularProgressIndicator() : _isServerRunning - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + ? SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( l10n.kioskReadyToSync, style: TextStyle(fontSize: 24, color: Colors.green), @@ -340,7 +586,14 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), - child: _buildLauncherSection(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLauncherSection(), + const SizedBox(height: 16), + _buildNetworkSection(), + ], + ), ), const SizedBox(height: 16), ElevatedButton( @@ -350,11 +603,13 @@ class _SettingsScreenState extends State { }, child: Text(l10n.close), ), - ], + ], + ), ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + : SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ const Icon(Icons.error_outline, size: 60, color: Colors.red), const SizedBox(height: 20), Text( @@ -369,14 +624,22 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), - child: _buildLauncherSection(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildLauncherSection(), + const SizedBox(height: 16), + _buildNetworkSection(), + ], + ), ), const SizedBox(height: 16), ElevatedButton( onPressed: _startServer, child: Text(l10n.retry), ), - ], + ], + ), ), ), ), diff --git a/Kiosk/lib/services/android_network_service.dart b/Kiosk/lib/services/android_network_service.dart new file mode 100644 index 0000000..1e728c5 --- /dev/null +++ b/Kiosk/lib/services/android_network_service.dart @@ -0,0 +1,80 @@ +import 'package:flutter/services.dart'; + +class HotspotInfo { + final bool enabled; + final String? mode; + final String? ssid; + final String? password; + + const HotspotInfo({ + required this.enabled, + required this.mode, + required this.ssid, + required this.password, + }); + + factory HotspotInfo.fromMap(Map map) { + return HotspotInfo( + enabled: map['enabled'] == true, + mode: map['mode']?.toString(), + ssid: map['ssid']?.toString(), + password: map['password']?.toString(), + ); + } +} + +class AndroidNetworkService { + static const MethodChannel _channel = MethodChannel('com.secgo.kiosk/network'); + + Future getHotspotEnabled() async { + final enabled = await _channel.invokeMethod('getHotspotEnabled'); + return enabled ?? false; + } + + Future getHotspotInfo() async { + final info = await _channel.invokeMethod('getHotspotInfo'); + if (info is Map) return HotspotInfo.fromMap(info); + return const HotspotInfo(enabled: false, mode: null, ssid: null, password: null); + } + + Future setHotspotEnabled(bool enabled) async { + final ok = await _channel.invokeMethod( + 'setHotspotEnabled', + {'enabled': enabled}, + ); + return ok ?? false; + } + + Future openHotspotSettings() async { + await _channel.invokeMethod('openHotspotSettings'); + } + + Future> getHotspotLastError() async { + final raw = await _channel.invokeMethod('getHotspotLastError'); + if (raw is Map) { + return raw.map((k, v) => MapEntry(k.toString(), v)); + } + return const {}; + } + + Future openLocationSettings() async { + await _channel.invokeMethod('openLocationSettings'); + } + + Future getMobileDataEnabled() async { + final enabled = await _channel.invokeMethod('getMobileDataEnabled'); + return enabled ?? false; + } + + Future setMobileDataEnabled(bool enabled) async { + final ok = await _channel.invokeMethod( + 'setMobileDataEnabled', + {'enabled': enabled}, + ); + return ok ?? false; + } + + Future openInternetSettings() async { + await _channel.invokeMethod('openInternetSettings'); + } +} diff --git a/Kiosk/lib/services/server/kiosk_server.dart b/Kiosk/lib/services/server/kiosk_server.dart index d14abf6..7563794 100644 --- a/Kiosk/lib/services/server/kiosk_server.dart +++ b/Kiosk/lib/services/server/kiosk_server.dart @@ -29,65 +29,18 @@ class KioskServerService { String? get ipAddress => _ipAddress; int get port => _port; + bool get isRunning => _server != null; Future startServer(String pin, {String? deviceId}) async { _pin = pin; _deviceId = deviceId; - - // Iterate interfaces to find a valid non-loopback IPv4 address - // Priority: Hotspot (ap0, tethering) -> Wi-Fi (wlan0) -> Exclude LTE (rmnet) - try { - final interfaces = await NetworkInterface.list( - includeLoopback: false, - type: InternetAddressType.IPv4, - ); - - NetworkInterface? selectedInterface; - - // 1. Prioritize Hotspot/Tethering interface (ap, tether, wlan1 usually) - try { - selectedInterface = interfaces.firstWhere( - (i) => i.name.toLowerCase().contains('ap') || - i.name.toLowerCase().contains('tether') - ); - } catch (_) {} - - // 2. If no Hotspot, try to find Wi-Fi interface (wlan) - if (selectedInterface == null) { - try { - selectedInterface = interfaces.firstWhere((i) => i.name.toLowerCase().contains('wlan')); - } catch (_) {} - } - // 3. If still nothing, look for any interface that IS NOT mobile data (rmnet, ccmni, pdp) - if (selectedInterface == null) { - try { - selectedInterface = interfaces.firstWhere( - (i) => !i.name.toLowerCase().contains('rmnet') && - !i.name.toLowerCase().contains('ccmni') && - !i.name.toLowerCase().contains('pdp') - ); - } catch (_) {} - } - - if (selectedInterface != null) { - debugPrint('Selected interface: ${selectedInterface.name}'); - for (var addr in selectedInterface.addresses) { - if (!addr.isLoopback) { - _ipAddress = addr.address; - break; - } - } - } - } catch (e) { - debugPrint('Error listing network interfaces: $e'); + if (_server != null) { + await refreshIpAddress(); + return; } - // Fallback to NetworkInfo if manual lookup fails (though manual is more robust for Hotspot/LTE) - if (_ipAddress == null) { - final info = NetworkInfo(); - _ipAddress = await info.getWifiIP(); - } + _ipAddress = await _resolveIpAddress(); if (_ipAddress == null) { debugPrint('Could not get IP address. Server not started.'); @@ -373,4 +326,62 @@ class KioskServerService { await _server?.close(); _server = null; } + + Future refreshIpAddress() async { + _ipAddress = await _resolveIpAddress(); + } + + Future _resolveIpAddress() async { + String? ip; + try { + final interfaces = await NetworkInterface.list( + includeLoopback: false, + type: InternetAddressType.IPv4, + ); + + NetworkInterface? selectedInterface; + + try { + selectedInterface = interfaces.firstWhere( + (i) => i.name.toLowerCase().contains('ap') || i.name.toLowerCase().contains('tether'), + ); + } catch (_) {} + + if (selectedInterface == null) { + try { + selectedInterface = interfaces.firstWhere((i) => i.name.toLowerCase().contains('wlan')); + } catch (_) {} + } + + if (selectedInterface == null) { + try { + selectedInterface = interfaces.firstWhere( + (i) => + !i.name.toLowerCase().contains('rmnet') && + !i.name.toLowerCase().contains('ccmni') && + !i.name.toLowerCase().contains('pdp'), + ); + } catch (_) {} + } + + if (selectedInterface != null) { + debugPrint('Selected interface: ${selectedInterface.name}'); + for (var addr in selectedInterface.addresses) { + if (!addr.isLoopback) { + ip = addr.address; + break; + } + } + } + } catch (e) { + debugPrint('Error listing network interfaces: $e'); + } + + if (ip == null) { + final info = NetworkInfo(); + ip = await info.getWifiIP(); + } + + return ip; + } } diff --git a/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift b/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift index 535949b..6d0fe86 100644 --- a/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/Kiosk/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import connectivity_plus import mobile_scanner import network_info_plus import package_info_plus @@ -14,6 +15,7 @@ import sqflite_darwin import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/Kiosk/pubspec.lock b/Kiosk/pubspec.lock index f3c4f0d..7c92d1e 100644 --- a/Kiosk/pubspec.lock +++ b/Kiosk/pubspec.lock @@ -145,6 +145,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" convert: dependency: transitive description: diff --git a/Kiosk/pubspec.yaml b/Kiosk/pubspec.yaml index fbf2d5b..381b7f9 100644 --- a/Kiosk/pubspec.yaml +++ b/Kiosk/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: http: ^1.6.0 intl: ^0.20.2 mobile_scanner: ^7.1.4 + connectivity_plus: ^6.1.4 network_info_plus: ^7.0.0 path: ^1.9.1 permission_handler: ^12.0.1 diff --git a/Kiosk/windows/flutter/generated_plugin_registrant.cc b/Kiosk/windows/flutter/generated_plugin_registrant.cc index 48de52b..1523d31 100644 --- a/Kiosk/windows/flutter/generated_plugin_registrant.cc +++ b/Kiosk/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/Kiosk/windows/flutter/generated_plugins.cmake b/Kiosk/windows/flutter/generated_plugins.cmake index 0e69e40..a103c74 100644 --- a/Kiosk/windows/flutter/generated_plugins.cmake +++ b/Kiosk/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus permission_handler_windows )