import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/mobile/pages/settings_page.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:window_manager/window_manager.dart'; import '../common.dart'; import '../common/formatter/id_formatter.dart'; import '../desktop/pages/server_page.dart' as desktop; import '../desktop/widgets/tabbar_widget.dart'; import '../mobile/pages/server_page.dart'; import 'model.dart'; const kLoginDialogTag = "LOGIN"; const kUseTemporaryPassword = "use-temporary-password"; const kUsePermanentPassword = "use-permanent-password"; const kUseBothPasswords = "use-both-passwords"; class ServerModel with ChangeNotifier { bool _isStart = false; // Android MainService status bool _mediaOk = false; bool _inputOk = false; bool _audioOk = false; bool _fileOk = false; bool _showElevation = false; bool hideCm = false; int _connectStatus = 0; // Rendezvous Server status String _verificationMethod = ""; String _temporaryPasswordLength = ""; String _approveMode = ""; int _zeroClientLengthCounter = 0; late String _emptyIdShow; late final IDTextEditingController _serverId; final _serverPasswd = TextEditingController(text: translate("Generating ...")); final tabController = DesktopTabController(tabType: DesktopTabType.cm); final List _clients = []; Timer? cmHiddenTimer; bool get isStart => _isStart; bool get mediaOk => _mediaOk; bool get inputOk => _inputOk; bool get audioOk => _audioOk; bool get fileOk => _fileOk; bool get showElevation => _showElevation; int get connectStatus => _connectStatus; String get verificationMethod { final index = [ kUseTemporaryPassword, kUsePermanentPassword, kUseBothPasswords ].indexOf(_verificationMethod); if (index < 0) { return kUseBothPasswords; } return _verificationMethod; } String get approveMode => _approveMode; setVerificationMethod(String method) async { await bind.mainSetOption(key: kOptionVerificationMethod, value: method); /* if (method != kUsePermanentPassword) { await bind.mainSetOption( key: 'allow-hide-cm', value: bool2option('allow-hide-cm', false)); } */ } String get temporaryPasswordLength { final lengthIndex = ["6", "8", "10"].indexOf(_temporaryPasswordLength); if (lengthIndex < 0) { return "6"; } return _temporaryPasswordLength; } setTemporaryPasswordLength(String length) async { await bind.mainSetOption(key: "temporary-password-length", value: length); } setApproveMode(String mode) async { await bind.mainSetOption(key: kOptionApproveMode, value: mode); /* if (mode != 'password') { await bind.mainSetOption( key: 'allow-hide-cm', value: bool2option('allow-hide-cm', false)); } */ } TextEditingController get serverId => _serverId; TextEditingController get serverPasswd => _serverPasswd; List get clients => _clients; final controller = ScrollController(); WeakReference parent; ServerModel(this.parent) { _emptyIdShow = translate("Generating ..."); _serverId = IDTextEditingController(text: _emptyIdShow); /* // initital _hideCm at startup final verificationMethod = bind.mainGetOptionSync(key: kOptionVerificationMethod); final approveMode = bind.mainGetOptionSync(key: kOptionApproveMode); _hideCm = option2bool( 'allow-hide-cm', bind.mainGetOptionSync(key: 'allow-hide-cm')); if (!(approveMode == 'password' && verificationMethod == kUsePermanentPassword)) { _hideCm = false; } */ timerCallback() async { final connectionStatus = jsonDecode(await bind.mainGetConnectStatus()) as Map; final statusNum = connectionStatus['status_num'] as int; if (statusNum != _connectStatus) { _connectStatus = statusNum; notifyListeners(); } if (desktopType == DesktopType.cm) { final res = await bind.cmCheckClientsLength(length: _clients.length); if (res != null) { debugPrint("clients not match!"); updateClientState(res); } else { if (_clients.isEmpty) { hideCmWindow(); if (_zeroClientLengthCounter++ == 12) { // 6 second windowManager.close(); } } else { _zeroClientLengthCounter = 0; if (!hideCm) showCmWindow(); } } } updatePasswordModel(); } if (!isTest) { Future.delayed(Duration.zero, () async { if (await bind.optionSynced()) { await timerCallback(); } }); Timer.periodic(Duration(milliseconds: 500), (timer) async { await timerCallback(); }); } // Initial keyboard status is off on mobile if (isMobile) { bind.mainSetOption(key: kOptionEnableKeyboard, value: 'N'); } } /// 1. check android permission /// 2. check config /// audio true by default (if permission on) (false default < Android 10) /// file true by default (if permission on) checkAndroidPermission() async { // audio if (androidVersion < 30 || !await AndroidPermissionManager.check(kRecordAudio)) { _audioOk = false; bind.mainSetOption(key: kOptionEnableAudio, value: "N"); } else { final audioOption = await bind.mainGetOption(key: kOptionEnableAudio); _audioOk = audioOption != 'N'; } // file if (!await AndroidPermissionManager.check(kManageExternalStorage)) { _fileOk = false; bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N"); } else { final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer); _fileOk = fileOption != 'N'; } notifyListeners(); } updatePasswordModel() async { var update = false; final temporaryPassword = await bind.mainGetTemporaryPassword(); final verificationMethod = await bind.mainGetOption(key: kOptionVerificationMethod); final temporaryPasswordLength = await bind.mainGetOption(key: "temporary-password-length"); final approveMode = await bind.mainGetOption(key: kOptionApproveMode); /* var hideCm = option2bool( 'allow-hide-cm', await bind.mainGetOption(key: 'allow-hide-cm')); if (!(approveMode == 'password' && verificationMethod == kUsePermanentPassword)) { hideCm = false; } */ if (_approveMode != approveMode) { _approveMode = approveMode; update = true; } var stopped = await mainGetBoolOption(kOptionStopService); final oldPwdText = _serverPasswd.text; if (stopped || verificationMethod == kUsePermanentPassword || _approveMode == 'click') { _serverPasswd.text = '-'; } else { if (_serverPasswd.text != temporaryPassword && temporaryPassword.isNotEmpty) { _serverPasswd.text = temporaryPassword; } } if (oldPwdText != _serverPasswd.text) { update = true; } if (_verificationMethod != verificationMethod) { _verificationMethod = verificationMethod; update = true; } if (_temporaryPasswordLength != temporaryPasswordLength) { if (_temporaryPasswordLength.isNotEmpty) { bind.mainUpdateTemporaryPassword(); } _temporaryPasswordLength = temporaryPasswordLength; update = true; } /* if (_hideCm != hideCm) { _hideCm = hideCm; if (desktopType == DesktopType.cm) { if (hideCm) { await hideCmWindow(); } else { await showCmWindow(); } } update = true; } */ if (update) { notifyListeners(); } } toggleAudio() async { if (clients.isNotEmpty) { await showClientsMayNotBeChangedAlert(parent.target); } if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) { final res = await AndroidPermissionManager.request(kRecordAudio); if (!res) { showToast(translate('Failed')); return; } } _audioOk = !_audioOk; bind.mainSetOption( key: kOptionEnableAudio, value: _audioOk ? defaultOptionYes : 'N'); notifyListeners(); } toggleFile() async { if (clients.isNotEmpty) { await showClientsMayNotBeChangedAlert(parent.target); } if (!_fileOk && !await AndroidPermissionManager.check(kManageExternalStorage)) { final res = await AndroidPermissionManager.request(kManageExternalStorage); if (!res) { showToast(translate('Failed')); return; } } _fileOk = !_fileOk; bind.mainSetOption( key: kOptionEnableFileTransfer, value: _fileOk ? defaultOptionYes : 'N'); notifyListeners(); } toggleInput() async { if (clients.isNotEmpty) { await showClientsMayNotBeChangedAlert(parent.target); } if (_inputOk) { parent.target?.invokeMethod("stop_input"); bind.mainSetOption(key: kOptionEnableKeyboard, value: 'N'); } else { if (parent.target != null) { /// the result of toggle-on depends on user actions in the settings page. /// handle result, see [ServerModel.changeStatue] showInputWarnAlert(parent.target!); } } } Future checkRequestNotificationPermission() async { debugPrint("androidVersion $androidVersion"); if (androidVersion < 33) { return true; } if (await AndroidPermissionManager.check(kAndroid13Notification)) { debugPrint("notification permission already granted"); return true; } var res = await AndroidPermissionManager.request(kAndroid13Notification); debugPrint("notification permission request result: $res"); return res; } Future checkFloatingWindowPermission() async { debugPrint("androidVersion $androidVersion"); if (androidVersion < 23) { return false; } if (await AndroidPermissionManager.check(kSystemAlertWindow)) { debugPrint("alert window permission already granted"); return true; } var res = await AndroidPermissionManager.request(kSystemAlertWindow); debugPrint("alert window permission request result: $res"); return res; } /// Toggle the screen sharing service. toggleService() async { if (_isStart) { final res = await parent.target?.dialogManager .show((setState, close, context) { submit() => close(true); return CustomAlertDialog( title: Row(children: [ const Icon(Icons.warning_amber_sharp, color: Colors.redAccent, size: 28), const SizedBox(width: 10), Text(translate("Warning")), ]), content: Text(translate("android_stop_service_tip")), actions: [ TextButton(onPressed: close, child: Text(translate("Cancel"))), TextButton(onPressed: submit, child: Text(translate("OK"))), ], onSubmit: submit, onCancel: close, ); }); if (res == true) { stopService(); } } else { await checkRequestNotificationPermission(); if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') { await checkFloatingWindowPermission(); } if (!await AndroidPermissionManager.check(kManageExternalStorage)) { await AndroidPermissionManager.request(kManageExternalStorage); } final res = await parent.target?.dialogManager .show((setState, close, context) { submit() => close(true); return CustomAlertDialog( title: Row(children: [ const Icon(Icons.warning_amber_sharp, color: Colors.redAccent, size: 28), const SizedBox(width: 10), Text(translate("Warning")), ]), content: Text(translate("android_service_will_start_tip")), actions: [ dialogButton("Cancel", onPressed: close, isOutline: true), dialogButton("OK", onPressed: submit), ], onSubmit: submit, onCancel: close, ); }); if (res == true) { startService(); } } } /// Start the screen sharing service. Future startService() async { _isStart = true; notifyListeners(); parent.target?.ffiModel.updateEventListener(parent.target!.sessionId, ""); await parent.target?.invokeMethod("init_service"); // ugly is here, because for desktop, below is useless await bind.mainStartService(); updateClientState(); if (isAndroid) { androidUpdatekeepScreenOn(); } } /// Stop the screen sharing service. Future stopService() async { _isStart = false; closeAll(); await parent.target?.invokeMethod("stop_service"); await bind.mainStopService(); notifyListeners(); if (!isLinux) { // current linux is not supported WakelockPlus.disable(); } } Future setPermanentPassword(String newPW) async { await bind.mainSetPermanentPassword(password: newPW); await Future.delayed(Duration(milliseconds: 500)); final pw = await bind.mainGetPermanentPassword(); if (newPW == pw) { return true; } else { return false; } } fetchID() async { final id = await bind.mainGetMyId(); if (id != _serverId.id) { _serverId.id = id; notifyListeners(); } } changeStatue(String name, bool value) { debugPrint("changeStatue value $value"); switch (name) { case "media": _mediaOk = value; if (value && !_isStart) { startService(); } break; case "input": if (_inputOk != value) { bind.mainSetOption( key: kOptionEnableKeyboard, value: value ? defaultOptionYes : 'N'); } _inputOk = value; break; default: return; } notifyListeners(); } // force updateClientState([String? json]) async { if (isTest) return; var res = await bind.cmGetClientsState(); List clientsJson; try { clientsJson = jsonDecode(res); } catch (e) { debugPrint("Failed to decode clientsJson: '$res', error $e"); return; } final oldClientLenght = _clients.length; _clients.clear(); tabController.state.value.tabs.clear(); for (var clientJson in clientsJson) { try { final client = Client.fromJson(clientJson); _clients.add(client); _addTab(client); } catch (e) { debugPrint("Failed to decode clientJson '$clientJson', error $e"); } } if (desktopType == DesktopType.cm) { if (_clients.isEmpty) { hideCmWindow(); } else if (!hideCm) { showCmWindow(); } } if (_clients.length != oldClientLenght) { notifyListeners(); if (isAndroid) androidUpdatekeepScreenOn(); } } void addConnection(Map evt) { try { final client = Client.fromJson(jsonDecode(evt["client"])); if (client.authorized) { parent.target?.dialogManager.dismissByTag(getLoginDialogTag(client.id)); final index = _clients.indexWhere((c) => c.id == client.id); if (index < 0) { _clients.add(client); } else { _clients[index].authorized = true; } } else { if (_clients.any((c) => c.id == client.id)) { return; } _clients.add(client); } _addTab(client); // remove disconnected final index_disconnected = _clients .indexWhere((c) => c.disconnected && c.peerId == client.peerId); if (index_disconnected >= 0) { _clients.removeAt(index_disconnected); tabController.remove(index_disconnected); } if (desktopType == DesktopType.cm && !hideCm) { showCmWindow(); } scrollToBottom(); notifyListeners(); if (isAndroid && !client.authorized) showLoginDialog(client); if (isAndroid) androidUpdatekeepScreenOn(); } catch (e) { debugPrint("Failed to call loginRequest,error:$e"); } } void _addTab(Client client) { tabController.add(TabInfo( key: client.id.toString(), label: client.name, closable: false, onTap: () {}, page: desktop.buildConnectionCard(client))); Future.delayed(Duration.zero, () async { if (!hideCm) windowOnTop(null); }); // Only do the hidden task when on Desktop. if (client.authorized && isDesktop) { cmHiddenTimer = Timer(const Duration(seconds: 3), () { if (!hideCm) windowManager.minimize(); cmHiddenTimer = null; }); } parent.target?.chatModel .updateConnIdOfKey(MessageKey(client.peerId, client.id)); } void showLoginDialog(Client client) { showClientDialog( client, client.isFileTransfer ? "File Connection" : "Screen Connection", 'Do you accept?', 'android_new_connection_tip', () => sendLoginResponse(client, false), () => sendLoginResponse(client, true), ); } handleVoiceCall(Client client, bool accept) { parent.target?.invokeMethod("cancel_notification", client.id); bind.cmHandleIncomingVoiceCall(id: client.id, accept: accept); } showVoiceCallDialog(Client client) { showClientDialog( client, 'Voice call', 'Do you accept?', 'android_new_voice_call_tip', () => handleVoiceCall(client, false), () => handleVoiceCall(client, true), ); } showClientDialog(Client client, String title, String contentTitle, String content, VoidCallback onCancel, VoidCallback onSubmit) { parent.target?.dialogManager.show((setState, close, context) { cancel() { onCancel(); close(); } submit() { onSubmit(); close(); } return CustomAlertDialog( title: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(translate(title)), IconButton(onPressed: close, icon: const Icon(Icons.close)) ]), content: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(translate(contentTitle)), ClientInfo(client), Text( translate(content), style: Theme.of(globalKey.currentContext!).textTheme.bodyMedium, ), ], ), actions: [ dialogButton("Dismiss", onPressed: cancel, isOutline: true), if (approveMode != 'password') dialogButton("Accept", onPressed: submit), ], onSubmit: submit, onCancel: cancel, ); }, tag: getLoginDialogTag(client.id)); } scrollToBottom() { if (isDesktop) return; Future.delayed(Duration(milliseconds: 200), () { controller.animateTo(controller.position.maxScrollExtent, duration: Duration(milliseconds: 200), curve: Curves.fastLinearToSlowEaseIn); }); } void sendLoginResponse(Client client, bool res) async { if (res) { bind.cmLoginRes(connId: client.id, res: res); if (!client.isFileTransfer) { parent.target?.invokeMethod("start_capture"); } parent.target?.invokeMethod("cancel_notification", client.id); client.authorized = true; notifyListeners(); } else { bind.cmLoginRes(connId: client.id, res: res); parent.target?.invokeMethod("cancel_notification", client.id); final index = _clients.indexOf(client); tabController.remove(index); _clients.remove(client); if (isAndroid) androidUpdatekeepScreenOn(); } } void onClientRemove(Map evt) { try { final id = int.parse(evt['id'] as String); final close = (evt['close'] as String) == 'true'; if (_clients.any((c) => c.id == id)) { final index = _clients.indexWhere((client) => client.id == id); if (index >= 0) { if (close) { _clients.removeAt(index); tabController.remove(index); } else { _clients[index].disconnected = true; } } parent.target?.dialogManager.dismissByTag(getLoginDialogTag(id)); parent.target?.invokeMethod("cancel_notification", id); } if (desktopType == DesktopType.cm && _clients.isEmpty) { hideCmWindow(); } if (isAndroid) androidUpdatekeepScreenOn(); notifyListeners(); } catch (e) { debugPrint("onClientRemove failed,error:$e"); } } Future closeAll() async { await Future.wait( _clients.map((client) => bind.cmCloseConnection(connId: client.id))); _clients.clear(); tabController.state.value.tabs.clear(); if (isAndroid) androidUpdatekeepScreenOn(); } void jumpTo(int id) { final index = _clients.indexWhere((client) => client.id == id); tabController.jumpTo(index); } void setShowElevation(bool show) { if (_showElevation != show) { _showElevation = show; notifyListeners(); } } void updateVoiceCallState(Map evt) { try { final client = Client.fromJson(jsonDecode(evt["client"])); final index = _clients.indexWhere((element) => element.id == client.id); if (index != -1) { _clients[index].inVoiceCall = client.inVoiceCall; _clients[index].incomingVoiceCall = client.incomingVoiceCall; if (client.incomingVoiceCall) { if (isAndroid) { showVoiceCallDialog(client); } else { // Has incoming phone call, let's set the window on top. Future.delayed(Duration.zero, () { windowOnTop(null); }); } } notifyListeners(); } } catch (e) { debugPrint("updateVoiceCallState failed: $e"); } } void androidUpdatekeepScreenOn() async { if (!isAndroid) return; var floatingWindowDisabled = bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) == "Y" || !await AndroidPermissionManager.check(kSystemAlertWindow); final keepScreenOn = floatingWindowDisabled ? KeepScreenOn.never : optionToKeepScreenOn( bind.mainGetLocalOption(key: kOptionKeepScreenOn)); final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) || (keepScreenOn == KeepScreenOn.duringControlled && _clients.map((e) => !e.disconnected).isNotEmpty); if (on != await WakelockPlus.enabled) { if (on) { WakelockPlus.enable(); } else { WakelockPlus.disable(); } } } } enum ClientType { remote, file, portForward, } class Client { int id = 0; // client connections inner count id bool authorized = false; bool isFileTransfer = false; String portForward = ""; String name = ""; String peerId = ""; // peer user's id,show at app bool keyboard = false; bool clipboard = false; bool audio = false; bool file = false; bool restart = false; bool recording = false; bool blockInput = false; bool disconnected = false; bool fromSwitch = false; bool inVoiceCall = false; bool incomingVoiceCall = false; RxInt unreadChatMessageCount = 0.obs; Client(this.id, this.authorized, this.isFileTransfer, this.name, this.peerId, this.keyboard, this.clipboard, this.audio); Client.fromJson(Map json) { id = json['id']; authorized = json['authorized']; isFileTransfer = json['is_file_transfer']; portForward = json['port_forward']; name = json['name']; peerId = json['peer_id']; keyboard = json['keyboard']; clipboard = json['clipboard']; audio = json['audio']; file = json['file']; restart = json['restart']; recording = json['recording']; blockInput = json['block_input']; disconnected = json['disconnected']; fromSwitch = json['from_switch']; inVoiceCall = json['in_voice_call']; incomingVoiceCall = json['incoming_voice_call']; } Map toJson() { final Map data = {}; data['id'] = id; data['authorized'] = authorized; data['is_file_transfer'] = isFileTransfer; data['port_forward'] = portForward; data['name'] = name; data['peer_id'] = peerId; data['keyboard'] = keyboard; data['clipboard'] = clipboard; data['audio'] = audio; data['file'] = file; data['restart'] = restart; data['recording'] = recording; data['block_input'] = blockInput; data['disconnected'] = disconnected; data['from_switch'] = fromSwitch; data['in_voice_call'] = inVoiceCall; data['incoming_voice_call'] = incomingVoiceCall; return data; } ClientType type_() { if (isFileTransfer) { return ClientType.file; } else if (portForward.isNotEmpty) { return ClientType.portForward; } else { return ClientType.remote; } } } String getLoginDialogTag(int id) { return kLoginDialogTag + id.toString(); } showInputWarnAlert(FFI ffi) { ffi.dialogManager.show((setState, close, context) { submit() { AndroidPermissionManager.startAction(kActionAccessibilitySettings); close(); } return CustomAlertDialog( title: Text(translate("How to get Android input permission?")), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text(translate("android_input_permission_tip1")), const SizedBox(height: 10), Text(translate("android_input_permission_tip2")), ], ), actions: [ dialogButton("Cancel", onPressed: close, isOutline: true), dialogButton("Open System Setting", onPressed: submit), ], onSubmit: submit, onCancel: close, ); }); } Future showClientsMayNotBeChangedAlert(FFI? ffi) async { await ffi?.dialogManager.show((setState, close, context) { return CustomAlertDialog( title: Text(translate("Permissions")), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text(translate("android_permission_may_not_change_tip")), ], ), actions: [ dialogButton("OK", onPressed: close), ], onSubmit: close, onCancel: close, ); }); }