diff --git a/android/build.gradle b/android/build.gradle index eab07ca5c..b8279233b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.5.10' repositories { google() jcenter() @@ -7,7 +7,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' + classpath 'com.android.tools.build:gradle:4.2.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" classpath 'com.google.gms:google-services:4.3.3' } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index bc6a58afd..3df6b3389 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-all.zip diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index aa93457cd..3886d2456 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -42,6 +42,12 @@ UIViewControllerBasedStatusBarAppearance ITSAppUsesNonExemptEncryption - + + io.flutter.embedded_views_preview + + NSCameraUsageDescription + This app needs camera access to scan QR codes + NSPhotoLibraryUsageDescription + This app needs photo library access to get QR codes from image diff --git a/lib/common.dart b/lib/common.dart index ef0ced2b0..3fb3ea502 100644 --- a/lib/common.dart +++ b/lib/common.dart @@ -42,6 +42,11 @@ final ButtonStyle flatButtonStyle = TextButton.styleFrom( ), ); +void showToast(String text) { + EasyLoading.showToast(Translator.call(text), + maskType: EasyLoadingMaskType.black); +} + void showLoading(String text) { DialogManager.reset(); EasyLoading.dismiss(); diff --git a/lib/pages/connection_page.dart b/lib/pages/connection_page.dart index deca3cee5..8a5ecdc22 100644 --- a/lib/pages/connection_page.dart +++ b/lib/pages/connection_page.dart @@ -19,7 +19,7 @@ class ConnectionPage extends StatefulWidget implements PageShape { final title = translate("Connection"); @override - final appBarActions = !isAndroid ? [WebMenu()] : []; + final appBarActions = isWeb ? [WebMenu()] : []; @override _ConnectionPageState createState() => _ConnectionPageState(); @@ -308,7 +308,7 @@ class _WebMenuState extends State { }, onSelected: (value) { if (value == 'server') { - showServer(); + showServerSettings(); } if (value == 'about') { showAbout(); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index ef895c042..c4f9e0c08 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -25,12 +25,9 @@ class _HomePageState extends State { @override void initState() { super.initState(); - _pages.addAll([ - ConnectionPage(), - chatPage, - ]); + _pages.add(ConnectionPage()); if (isAndroid) { - _pages.add(ServerPage()); + _pages.addAll([chatPage, ServerPage()]); } _pages.add(SettingsPage()); } diff --git a/lib/pages/scan_page.dart b/lib/pages/scan_page.dart new file mode 100644 index 000000000..52e1f1bf4 --- /dev/null +++ b/lib/pages/scan_page.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:qr_code_scanner/qr_code_scanner.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:image/image.dart' as img; +import 'package:zxing2/qrcode.dart'; +import 'dart:io'; +import 'dart:convert'; +import '../common.dart'; +import '../models/model.dart'; + +class ScanPage extends StatefulWidget { + @override + _ScanPageState createState() => _ScanPageState(); +} + +class _ScanPageState extends State { + QRViewController? controller; + final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + + // In order to get hot reload to work we need to pause the camera if the platform + // is android, or resume the camera if the platform is iOS. + @override + void reassemble() { + super.reassemble(); + if (isAndroid) { + controller!.pauseCamera(); + } + controller!.resumeCamera(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan QR'), + actions: [ + IconButton( + color: Colors.white, + icon: Icon(Icons.image_search), + iconSize: 32.0, + onPressed: () async { + final ImagePicker _picker = ImagePicker(); + final XFile? file = + await _picker.pickImage(source: ImageSource.gallery); + if (file != null) { + var image = img.decodeNamedImage( + File(file.path).readAsBytesSync(), file.path)!; + + LuminanceSource source = RGBLuminanceSource( + image.width, + image.height, + image + .getBytes(format: img.Format.abgr) + .buffer + .asInt32List()); + var bitmap = BinaryBitmap(HybridBinarizer(source)); + + var reader = QRCodeReader(); + try { + var result = reader.decode(bitmap); + showServerSettingFromQr(result.text); + } catch (e) { + showToast('No QR code found'); + } + } + }), + IconButton( + color: Colors.yellow, + icon: Icon(Icons.flash_on), + iconSize: 32.0, + onPressed: () async { + await controller?.toggleFlash(); + }), + IconButton( + color: Colors.white, + icon: Icon(Icons.switch_camera), + iconSize: 32.0, + onPressed: () async { + await controller?.flipCamera(); + }, + ), + ], + ), + body: _buildQrView(context)); + } + + Widget _buildQrView(BuildContext context) { + // For this example we check how width or tall the device is and change the scanArea and overlay accordingly. + var scanArea = (MediaQuery.of(context).size.width < 400 || + MediaQuery.of(context).size.height < 400) + ? 150.0 + : 300.0; + // To ensure the Scanner view is properly sizes after rotation + // we need to listen for Flutter SizeChanged notification and update controller + return QRView( + key: qrKey, + onQRViewCreated: _onQRViewCreated, + overlay: QrScannerOverlayShape( + borderColor: Colors.red, + borderRadius: 10, + borderLength: 30, + borderWidth: 10, + cutOutSize: scanArea), + onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p), + ); + } + + void _onQRViewCreated(QRViewController controller) { + setState(() { + this.controller = controller; + }); + controller.scannedDataStream.listen((scanData) { + if (scanData.code != null) { + showServerSettingFromQr(scanData.code!); + } + }); + } + + void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) { + if (!p) { + showToast('No permisssion'); + } + } + + @override + void dispose() { + controller?.dispose(); + super.dispose(); + } + + void showServerSettingFromQr(String data) async { + backToHome(); + await controller!.stopCamera(); + if (!data.startsWith('config=')) { + showToast('Invalid QR code'); + return; + } + try { + Map values = json.decode(data.substring(7)); + var host = values['host'] != null ? values['host'] as String : ''; + var key = values['key'] != null ? values['key'] as String : ''; + var api = values['api'] != null ? values['api'] as String : ''; + showServerSettingsWithValue(host, '', key, api); + } catch (e) { + showToast('Invalid QR code'); + } + } +} + +void showServerSettingsWithValue( + String id, String relay, String key, String api) { + final formKey = GlobalKey(); + final id0 = FFI.getByName('option', 'custom-rendezvous-server'); + final relay0 = FFI.getByName('option', 'relay-server'); + final api0 = FFI.getByName('option', 'api-server'); + final key0 = FFI.getByName('option', 'key'); + DialogManager.show((setState, close) { + return CustomAlertDialog( + title: Text(translate('ID/Relay Server')), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + initialValue: id, + decoration: InputDecoration( + labelText: translate('ID Server'), + ), + validator: validate, + onSaved: (String? value) { + if (value != null) id = value.trim(); + }, + ) + ] + + (isAndroid + ? [ + TextFormField( + initialValue: relay, + decoration: InputDecoration( + labelText: translate('Relay Server'), + ), + validator: validate, + onSaved: (String? value) { + if (value != null) relay = value.trim(); + }, + ) + ] + : []) + + [ + TextFormField( + initialValue: api, + decoration: InputDecoration( + labelText: translate('API Server'), + ), + validator: validate, + onSaved: (String? value) { + if (value != null) api = value.trim(); + }, + ), + TextFormField( + initialValue: key, + decoration: InputDecoration( + labelText: 'Key', + ), + validator: null, + onSaved: (String? value) { + if (value != null) key = value.trim(); + }, + ), + ])), + actions: [ + TextButton( + style: flatButtonStyle, + onPressed: () { + close(); + }, + child: Text(translate('Cancel')), + ), + TextButton( + style: flatButtonStyle, + onPressed: () { + if (formKey.currentState != null && + formKey.currentState!.validate()) { + formKey.currentState!.save(); + if (id != id0) + FFI.setByName('option', + '{"name": "custom-rendezvous-server", "value": "$id"}'); + if (relay != relay0) + FFI.setByName( + 'option', '{"name": "relay-server", "value": "$relay"}'); + if (key != key0) + FFI.setByName('option', '{"name": "key", "value": "$key"}'); + if (api != api0) + FFI.setByName( + 'option', '{"name": "api-server", "value": "$api"}'); + close(); + } + }, + child: Text(translate('OK')), + ), + ], + onWillPop: () async { + return true; + }, + ); + }, barrierDismissible: true); +} + +String? validate(value) { + value = value.trim(); + if (value.isEmpty) { + return null; + } + final res = FFI.getByName('test_if_valid_server', value); + return res.isEmpty ? null : res; +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index dff6b5957..d9527ba36 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -9,6 +9,7 @@ import '../common.dart'; import '../widgets/dialog.dart'; import '../models/model.dart'; import 'home_page.dart'; +import 'scan_page.dart'; class SettingsPage extends StatefulWidget implements PageShape { @override @@ -18,7 +19,7 @@ class SettingsPage extends StatefulWidget implements PageShape { final icon = Icon(Icons.settings); @override - final appBarActions = []; + final appBarActions = [ScanButton()]; @override _SettingsState createState() => _SettingsState(); @@ -58,7 +59,7 @@ class _SettingsState extends State { title: Text(translate('ID/Relay Server')), leading: Icon(Icons.cloud), onPressed: (context) { - showServer(); + showServerSettings(); }, ), ], @@ -67,20 +68,18 @@ class _SettingsState extends State { title: Text(translate("About")), tiles: [ SettingsTile.navigation( + onPressed: (context) async { + if (await canLaunch(url)) { + await launch(url); + } + }, title: Text(translate("Version: ") + version), - value: InkWell( - onTap: () async { - if (await canLaunch(url)) { - await launch(url); - } - }, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Text('rustdesk.com', - style: TextStyle( - decoration: TextDecoration.underline, - )), - ), + value: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text('rustdesk.com', + style: TextStyle( + decoration: TextDecoration.underline, + )), ), leading: Icon(Icons.info)), ], @@ -90,116 +89,12 @@ class _SettingsState extends State { } } -void showServer() { - final formKey = GlobalKey(); - final id0 = FFI.getByName('option', 'custom-rendezvous-server'); - final relay0 = FFI.getByName('option', 'relay-server'); - final api0 = FFI.getByName('option', 'api-server'); - final key0 = FFI.getByName('option', 'key'); - var id = ''; - var relay = ''; - var key = ''; - var api = ''; - DialogManager.show((setState, close) { - return CustomAlertDialog( - title: Text(translate('ID/Relay Server')), - content: Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - initialValue: id0, - decoration: InputDecoration( - labelText: translate('ID Server'), - ), - validator: validate, - onSaved: (String? value) { - if (value != null) id = value.trim(); - }, - ) - ] + - (isAndroid - ? [ - TextFormField( - initialValue: relay0, - decoration: InputDecoration( - labelText: translate('Relay Server'), - ), - validator: validate, - onSaved: (String? value) { - if (value != null) relay = value.trim(); - }, - ) - ] - : []) + - [ - TextFormField( - initialValue: api0, - decoration: InputDecoration( - labelText: translate('API Server'), - ), - validator: validate, - onSaved: (String? value) { - if (value != null) api = value.trim(); - }, - ), - TextFormField( - initialValue: key0, - decoration: InputDecoration( - labelText: 'Key', - ), - validator: null, - onSaved: (String? value) { - if (value != null) key = value.trim(); - }, - ), - ])), - actions: [ - TextButton( - style: flatButtonStyle, - onPressed: () { - close(); - }, - child: Text(translate('Cancel')), - ), - TextButton( - style: flatButtonStyle, - onPressed: () { - if (formKey.currentState != null && - formKey.currentState!.validate()) { - formKey.currentState!.save(); - if (id != id0) - FFI.setByName('option', - '{"name": "custom-rendezvous-server", "value": "$id"}'); - if (relay != relay0) - FFI.setByName( - 'option', '{"name": "relay-server", "value": "$relay"}'); - if (key != key0) - FFI.setByName('option', '{"name": "key", "value": "$key"}'); - if (api != api0) - FFI.setByName( - 'option', '{"name": "api-server", "value": "$api"}'); - close(); - } - }, - child: Text(translate('OK')), - ), - ], - onWillPop: () async { - return true; - }, - ); - }, barrierDismissible: true); -} - -String? validate(value) { - value = value.trim(); - if (value.isEmpty) { - return null; - } - final res = FFI.getByName('test_if_valid_server', value); - return res.isEmpty ? null : res; +void showServerSettings() { + final id = FFI.getByName('option', 'custom-rendezvous-server'); + final relay = FFI.getByName('option', 'relay-server'); + final api = FFI.getByName('option', 'api-server'); + final key = FFI.getByName('option', 'key'); + showServerSettingsWithValue(id, relay, key, api); } void showAbout() { @@ -448,3 +343,20 @@ String? getUsername() { } return username; } + +class ScanButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(Icons.qr_code_scanner), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => ScanPage(), + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 334ff42e9..72376a01c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.2" crypto: dependency: transitive description: @@ -176,6 +183,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.6.2" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" flutter: dependency: "direct main" description: flutter @@ -209,6 +223,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.2.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" flutter_spinkit: dependency: transitive description: @@ -241,12 +262,47 @@ packages: source: hosted version: "4.0.0" image: - dependency: transitive + dependency: "direct main" description: name: image url: "https://pub.dartlang.org" source: hosted version: "3.1.3" + image_picker: + dependency: "direct main" + description: + name: image_picker + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.5" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.4+11" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.4+11" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.4" intl: dependency: transitive description: @@ -394,6 +450,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.0.0" + qr_code_scanner: + dependency: "direct main" + description: + name: qr_code_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" quiver: dependency: transitive description: @@ -672,6 +735,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.0" + zxing2: + dependency: "direct main" + description: + name: zxing2 + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" sdks: dart: ">=2.16.0 <3.0.0" flutter: ">=2.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 44d9337ab..1309227f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,10 @@ dependencies: settings_ui: ^2.0.2 flutter_breadcrumb: ^1.0.1 http: ^0.13.4 + qr_code_scanner: ^0.7.0 + zxing2: ^0.1.0 + image_picker: ^0.8.5 + image: ^3.1.3 dev_dependencies: flutter_launcher_icons: ^0.9.1