import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'rust/api.dart' as nd; import 'theme.dart'; /// "Allow remote control" — the Android HOST role. /// /// Turns this device into a NetDesk host (like RustDesk): once the user grants /// screen capture and enables the accessibility service, a remote viewer can see /// and control it. Talks to the native side over the `space.netdesk/host` /// MethodChannel (see MainActivity.kt / HostService.kt). /// /// Preview: screen capture + the controls are live; the remote-connection wiring /// (register by Partner ID, send the encrypted H.264 to a viewer) lands next. class HostScreen extends StatefulWidget { const HostScreen({super.key}); @override State createState() => _HostScreenState(); } class _HostScreenState extends State with WidgetsBindingObserver { static const _host = MethodChannel('space.netdesk/host'); static const _frameChannel = EventChannel('space.netdesk/frames'); static const _wsUrl = 'wss://netdesk.space/ws'; bool _running = false; bool _accessibility = false; bool _hideIcon = false; bool _unattended = false; int _frames = 0; bool _busy = false; // Rust host session. BigInt? _handle; String _id = ''; String _password = ''; bool _viewerConnected = false; StreamSubscription? _frameSub; StreamSubscription? _eventSub; // Viewer-mouse -> touch gesture interpretation (coords are in capture space; // MainActivity scales to the real display before injecting). int _btns = 0; double _downX = 0, _downY = 0, _lastX = 0, _lastY = 0; int _downMs = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _refresh(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _frameSub?.cancel(); _eventSub?.cancel(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) _refresh(); } Future _call(String method, [Map? args]) async { try { return await _host.invokeMethod(method, args); } catch (_) { return null; } } Future _refresh() async { final running = await _call('isHostRunning') ?? false; final acc = await _call('hasAccessibility') ?? false; final hide = await _call('isIconHidden') ?? false; final un = await _call('isUnattended') ?? false; final frames = await _call('lastFrameCount') ?? 0; if (mounted) { setState(() { _running = running; _accessibility = acc; _hideIcon = hide; _unattended = un; _frames = frames; }); } } Future _toggleHost() async { setState(() => _busy = true); if (_running) { await _stopHostFull(); } else { await _startHostFull(); } if (mounted) setState(() => _busy = false); } /// Load the persisted host id + unattended password, generating them once. Future _loadCreds() async { var id = await _call('getHostId') ?? ''; var pw = await _call('getHostPassword') ?? ''; if (id.isEmpty || pw.isEmpty) { id = nd.hostGenId(); pw = nd.hostGenPassword(); await _call('setHostCreds', {'id': id, 'password': pw}); } if (mounted) { setState(() { _id = id; _password = pw; }); } } Future _startHostFull() async { await _loadCreds(); // 1) screen-capture consent -> HostService starts encoding. final ok = await _call('startHost') ?? false; if (!ok) { await _refresh(); return; } // 2) wait for the encoder to report a capture size. int w = 0, h = 0; for (var i = 0; i < 30; i++) { await Future.delayed(const Duration(milliseconds: 200)); final s = await _call>('hostScreenSize'); if (s != null && (s['w'] as int? ?? 0) > 0) { w = s['w'] as int; h = s['h'] as int; break; } } if (w == 0) { await _refresh(); return; } // 3) register the Rust host + wire frames out and input in. final handle = await nd.hostStart( wsUrl: _wsUrl, hostId: _id, name: 'Android', password: _password, width: w, height: h, ); _handle = handle; _frameSub = _frameChannel.receiveBroadcastStream().listen(_onFrame); _eventSub = nd.hostEvents(handle: handle).listen(_onHostEvent); await _refresh(); } Future _stopHostFull() async { await _frameSub?.cancel(); _frameSub = null; await _eventSub?.cancel(); _eventSub = null; final h = _handle; _handle = null; if (h != null) nd.hostStop(handle: h); await _call('stopHost'); if (mounted) setState(() => _viewerConnected = false); await Future.delayed(const Duration(milliseconds: 300)); await _refresh(); } void _onFrame(dynamic payload) { final h = _handle; if (h == null || payload is! Uint8List || payload.isEmpty) return; final keyframe = payload[0] == 1; final data = Uint8List.sublistView(payload, 1); nd.hostPushFrame(handle: h, data: data, keyframe: keyframe); } void _onHostEvent(nd.NdHostEvent ev) { switch (ev) { case nd.NdHostEvent_Mouse(:final x, :final y, :final buttons, :final wheel): _onMouse(x, y, buttons, wheel); case nd.NdHostEvent_Key(:final code, :final down): _call('injectKey', {'code': code, 'down': down}); case nd.NdHostEvent_KeyframeRequested(): _call('requestKeyframe'); case nd.NdHostEvent_ViewerConnected(): if (mounted) setState(() => _viewerConnected = true); case nd.NdHostEvent_ViewerLeft(): if (mounted) setState(() => _viewerConnected = false); default: break; } } /// Translate the viewer's absolute pointer into Android gestures: a quick /// press+release = tap, a moved drag = swipe, wheel = scroll. void _onMouse(int x, int y, int buttons, double wheel) { if (wheel != 0) { final dy = (wheel.clamp(-3.0, 3.0) * 140).round(); _call('injectSwipe', { 'x1': x.toDouble(), 'y1': y.toDouble(), 'x2': x.toDouble(), 'y2': (y + dy).toDouble(), 'dur': 120, }); return; } final leftNow = (buttons & 1) != 0; final leftWas = (_btns & 1) != 0; _btns = buttons; if (leftNow && !leftWas) { _downX = x.toDouble(); _downY = y.toDouble(); _lastX = _downX; _lastY = _downY; _downMs = DateTime.now().millisecondsSinceEpoch; } else if (leftNow && leftWas) { _lastX = x.toDouble(); _lastY = y.toDouble(); } else if (!leftNow && leftWas) { _lastX = x.toDouble(); _lastY = y.toDouble(); final dist = (_lastX - _downX).abs() + (_lastY - _downY).abs(); if (dist < 12) { _call('injectTap', {'x': _downX, 'y': _downY}); } else { final dur = (DateTime.now().millisecondsSinceEpoch - _downMs) .clamp(40, 800); _call('injectSwipe', { 'x1': _downX, 'y1': _downY, 'x2': _lastX, 'y2': _lastY, 'dur': dur, }); } } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: NetDeskColors.background, appBar: AppBar( backgroundColor: NetDeskColors.background, elevation: 0, title: const Text('Allow remote control'), ), body: SafeArea( child: RefreshIndicator( color: NetDeskColors.accent, onRefresh: _refresh, child: ListView( padding: const EdgeInsets.all(16), children: [ _statusCard(), if (_running && _id.isNotEmpty) ...[ const SizedBox(height: 14), _credsCard(), ], const SizedBox(height: 14), _startStopButton(), const SizedBox(height: 18), _tile( icon: Icons.touch_app_outlined, title: 'Remote input (accessibility)', subtitle: _accessibility ? 'Enabled — a viewer can tap and swipe this device.' : 'Required so a viewer can tap and swipe. Tap to enable.', trailing: _accessibility ? const Icon(Icons.check_circle, color: NetDeskColors.accent) : const Icon(Icons.chevron_right, color: NetDeskColors.textMuted), onTap: _accessibility ? null : () async { await _call('openAccessibilitySettings'); }, ), _switchTile( icon: Icons.schedule, title: 'Unattended access', subtitle: 'Start the host automatically after the device reboots.', value: _unattended, onChanged: (v) async { await _call('setUnattended', {'on': v}); await _refresh(); }, ), _switchTile( icon: Icons.visibility_off_outlined, title: 'Hide app icon', subtitle: 'Remove NetDesk from the app drawer (it keeps running). ' 'Reopen it from your browser at netdesk.space.', value: _hideIcon, onChanged: (v) async { await _call('setHideIcon', {'hide': v}); await _refresh(); }, ), const SizedBox(height: 16), const Padding( padding: EdgeInsets.symmetric(horizontal: 4), child: Text( 'Enter the ID and password above in any NetDesk viewer ' '(Windows, web or another phone) to see and control this ' 'device. Keep the screen-capture notification on. ' 'Developed by Akronlogic.', style: TextStyle( color: NetDeskColors.textMuted, fontSize: 12, height: 1.4), ), ), ], ), ), ), ); } Widget _statusCard() { final on = _running; return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: NetDeskColors.surface, borderRadius: BorderRadius.circular(NetDeskRadius.lg), border: Border.all(color: NetDeskColors.border), ), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: on ? NetDeskColors.accentGlow : NetDeskColors.surfaceAlt, borderRadius: BorderRadius.circular(12), ), child: Icon( on ? Icons.cast_connected : Icons.cast, color: on ? NetDeskColors.accent : NetDeskColors.textSecondary, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( on ? 'Host is active' : 'Host is off', style: const TextStyle( color: NetDeskColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 2), Text( on ? 'Capturing the screen · $_frames frames encoded' : 'This device cannot be controlled yet', style: const TextStyle( color: NetDeskColors.textSecondary, fontSize: 13), ), ], ), ), ], ), ); } Widget _credsCard() { return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: NetDeskColors.surface, borderRadius: BorderRadius.circular(NetDeskRadius.lg), border: Border.all( color: _viewerConnected ? NetDeskColors.accent : NetDeskColors.border, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( _viewerConnected ? Icons.lock_person : Icons.vpn_key_outlined, size: 18, color: _viewerConnected ? NetDeskColors.accent : NetDeskColors.textSecondary, ), const SizedBox(width: 8), Text( _viewerConnected ? 'A viewer is connected' : 'Ready for a viewer', style: TextStyle( color: _viewerConnected ? NetDeskColors.accent : NetDeskColors.textSecondary, fontSize: 13, fontWeight: FontWeight.w600, ), ), ], ), const SizedBox(height: 14), _credRow('Your ID', _id), const SizedBox(height: 10), _credRow('Password', _password), ], ), ); } Widget _credRow(String label, String value) { return Row( children: [ SizedBox( width: 86, child: Text(label, style: const TextStyle( color: NetDeskColors.textMuted, fontSize: 13)), ), Expanded( child: SelectableText( value, style: const TextStyle( color: NetDeskColors.textPrimary, fontSize: 19, fontWeight: FontWeight.w700, letterSpacing: 1.5, ), ), ), IconButton( icon: const Icon(Icons.copy_rounded, size: 18, color: NetDeskColors.textSecondary), onPressed: () { Clipboard.setData(ClipboardData(text: value)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$label copied'), duration: const Duration(seconds: 1), ), ); }, ), ], ); } Widget _startStopButton() { return SizedBox( height: 52, child: ElevatedButton( onPressed: _busy ? null : _toggleHost, style: ElevatedButton.styleFrom( backgroundColor: _running ? NetDeskColors.danger : NetDeskColors.accent, foregroundColor: _running ? Colors.white : const Color(0xFF04210F), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(NetDeskRadius.md), ), ), child: _busy ? const SizedBox( width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2.4), ) : Text( _running ? 'Stop hosting' : 'Start hosting this device', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700), ), ), ); } Widget _tile({ required IconData icon, required String title, required String subtitle, Widget? trailing, VoidCallback? onTap, }) { return Container( margin: const EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: NetDeskColors.surface, borderRadius: BorderRadius.circular(NetDeskRadius.md), border: Border.all(color: NetDeskColors.border), ), child: ListTile( onTap: onTap, leading: Icon(icon, color: NetDeskColors.textSecondary), title: Text(title, style: const TextStyle( color: NetDeskColors.textPrimary, fontWeight: FontWeight.w600)), subtitle: Text(subtitle, style: const TextStyle( color: NetDeskColors.textMuted, fontSize: 12.5)), trailing: trailing, ), ); } Widget _switchTile({ required IconData icon, required String title, required String subtitle, required bool value, required ValueChanged onChanged, }) { return Container( margin: const EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: NetDeskColors.surface, borderRadius: BorderRadius.circular(NetDeskRadius.md), border: Border.all(color: NetDeskColors.border), ), child: SwitchListTile( value: value, onChanged: onChanged, activeThumbColor: NetDeskColors.accent, secondary: Icon(icon, color: NetDeskColors.textSecondary), title: Text(title, style: const TextStyle( color: NetDeskColors.textPrimary, fontWeight: FontWeight.w600)), subtitle: Text(subtitle, style: const TextStyle( color: NetDeskColors.textMuted, fontSize: 12.5)), ), ); } }