mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-05 23:51:04 +03:00
* fix: android: replace all-files access with scoped storage + system picker Remove MANAGE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE, and WRITE_EXTERNAL_STORAGE from the Android manifest. Remove requestLegacyExternalStorage. Replace broad external storage with app-scoped external storage for the file-transfer workspace. File import uses the system file_picker. File export uses Android's SAF ACTION_CREATE_DOCUMENT with path validation that restricts export sources to app-owned directories. Remove the external_path dependency. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: refine file import feedback Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: use SAF for file imports Replace file_picker imports with Android's Storage Access Framework to avoid legacy storage permissions, stale cached files, and duplicate staging of large imports. Stream selected documents into app-scoped storage with failure-safe replacement, keep exports restricted to validated app storage roots, use filesDir for the internal fallback workspace, and remove legacy permissions contributed during manifest merging. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: keep file imports in the selected directory Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: reset projection and constrain file workspace Release capture resources when media projection is revoked externally. Keep Android local file navigation within the app-scoped workspace. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: handle scoped storage start-up regressions. Allow zero digits in POSIX filenames by rejecting NUL explicitly, and initialise the app-specific home directory before the Android service starts the native server. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: update content resolver mode to use 'wt' instead of 'w' to prevent trailing bytes from old document whilst reporting sucess Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android, enforce file workspace boundary on the server, and unblock the ui thread. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: validate rename destinations against the app workspace bound file-operation paths. report rename failures, general import failures, and unregister / reregister projection when its onStop callback fires. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: reconnect was refreshing the directory with net entry instances, while selected items retained the old instances, it was reporting a selected item, but checkbox statue used object identity, and appeared unchecked. Fixed by reconciling by path and entry type before replacing the directory snapshot, rebinding valid selections, and dropping missing ones. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: (android) add SAF folder import and multi item export - import directories using ACTION_OPEN_DOCUMENT_TREE. Export multiple files, logs, and screen recordings via export buttons, add localisation keys for new actions Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix(android): harden scoped storage file handling - create new SAF documents instead of overwriting export sources - reject empty peer paths except for home directory reads - report directory backup restore and cleanup failures - resolve log export paths from the configured app name Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): harden scoped-storage file operations - snapshot directory exports before writing to the destination - query document provider metadata off the main thread - reject invalid remote directories without read timeouts Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): handle SAF directory name collisions - reject dot-segment folder names during import - fail imports with duplicate document display names - only reuse matching directories during export Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): handle SAF folder import collisions Reject filesystem-equivalent destination names and avoid showing a failure when folder overwrite is skipped. Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: fufesou <linlong1266@gmail.com>
960 lines
33 KiB
Dart
960 lines
33 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
|
|
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
|
|
import 'package:flutter_hbb/models/chat_model.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../common.dart';
|
|
import '../../common/widgets/dialog.dart';
|
|
import '../../consts.dart';
|
|
import '../../models/platform_model.dart';
|
|
import '../../models/server_model.dart';
|
|
import 'home_page.dart';
|
|
|
|
class ServerPage extends StatefulWidget implements PageShape {
|
|
@override
|
|
final title = translate("Share screen");
|
|
|
|
@override
|
|
final icon = const Icon(Icons.mobile_screen_share);
|
|
|
|
@override
|
|
final appBarActions = (!bind.isDisableSettings() &&
|
|
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y')
|
|
? [_DropDownAction()]
|
|
: [];
|
|
|
|
ServerPage({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _ServerPageState();
|
|
}
|
|
|
|
class _DropDownAction extends StatelessWidget {
|
|
_DropDownAction();
|
|
|
|
// should only have one action
|
|
final actions = [
|
|
PopupMenuButton<String>(
|
|
tooltip: "",
|
|
icon: const Icon(Icons.more_vert),
|
|
itemBuilder: (context) {
|
|
listTile(String text, bool checked) {
|
|
return ListTile(
|
|
title: Text(translate(text)),
|
|
trailing: Icon(
|
|
Icons.check,
|
|
color: checked ? null : Colors.transparent,
|
|
));
|
|
}
|
|
|
|
final approveMode = gFFI.serverModel.approveMode;
|
|
final verificationMethod = gFFI.serverModel.verificationMethod;
|
|
final showPasswordOption = approveMode != 'click';
|
|
final isApproveModeFixed = isOptionFixed(kOptionApproveMode);
|
|
final isNumericOneTimePasswordFixed =
|
|
isOptionFixed(kOptionAllowNumericOneTimePassword);
|
|
final isAllowNumericOneTimePassword =
|
|
gFFI.serverModel.allowNumericOneTimePassword;
|
|
return [
|
|
if (!isChangeIdDisabled())
|
|
PopupMenuItem(
|
|
enabled: gFFI.serverModel.connectStatus > 0,
|
|
value: "changeID",
|
|
child: Text(translate("Change ID")),
|
|
),
|
|
if (!isChangeIdDisabled()) const PopupMenuDivider(),
|
|
PopupMenuItem(
|
|
value: 'AcceptSessionsViaPassword',
|
|
child: listTile(
|
|
'Accept sessions via password', approveMode == 'password'),
|
|
enabled: !isApproveModeFixed,
|
|
),
|
|
PopupMenuItem(
|
|
value: 'AcceptSessionsViaClick',
|
|
child:
|
|
listTile('Accept sessions via click', approveMode == 'click'),
|
|
enabled: !isApproveModeFixed,
|
|
),
|
|
PopupMenuItem(
|
|
value: "AcceptSessionsViaBoth",
|
|
child: listTile("Accept sessions via both",
|
|
approveMode != 'password' && approveMode != 'click'),
|
|
enabled: !isApproveModeFixed,
|
|
),
|
|
if (showPasswordOption) const PopupMenuDivider(),
|
|
if (showPasswordOption &&
|
|
verificationMethod != kUseTemporaryPassword &&
|
|
!isChangePermanentPasswordDisabled())
|
|
PopupMenuItem(
|
|
value: "setPermanentPassword",
|
|
child: Text(translate("Set permanent password")),
|
|
),
|
|
if (showPasswordOption &&
|
|
verificationMethod != kUsePermanentPassword)
|
|
PopupMenuItem(
|
|
value: "setTemporaryPasswordLength",
|
|
child: Text(translate("One-time password length")),
|
|
),
|
|
if (showPasswordOption &&
|
|
verificationMethod != kUsePermanentPassword)
|
|
PopupMenuItem(
|
|
value: "allowNumericOneTimePassword",
|
|
child: listTile(translate("Numeric one-time password"),
|
|
isAllowNumericOneTimePassword),
|
|
enabled: !isNumericOneTimePasswordFixed,
|
|
),
|
|
if (showPasswordOption) const PopupMenuDivider(),
|
|
if (showPasswordOption)
|
|
PopupMenuItem(
|
|
value: kUseTemporaryPassword,
|
|
child: listTile('Use one-time password',
|
|
verificationMethod == kUseTemporaryPassword),
|
|
),
|
|
if (showPasswordOption)
|
|
PopupMenuItem(
|
|
value: kUsePermanentPassword,
|
|
child: listTile('Use permanent password',
|
|
verificationMethod == kUsePermanentPassword),
|
|
),
|
|
if (showPasswordOption)
|
|
PopupMenuItem(
|
|
value: kUseBothPasswords,
|
|
child: listTile(
|
|
'Use both passwords',
|
|
verificationMethod != kUseTemporaryPassword &&
|
|
verificationMethod != kUsePermanentPassword),
|
|
),
|
|
];
|
|
},
|
|
onSelected: (value) async {
|
|
if (value == "changeID") {
|
|
changeIdDialog();
|
|
} else if (value == "setPermanentPassword") {
|
|
setPasswordDialog();
|
|
} else if (value == "setTemporaryPasswordLength") {
|
|
setTemporaryPasswordLengthDialog(gFFI.dialogManager);
|
|
} else if (value == "allowNumericOneTimePassword") {
|
|
gFFI.serverModel.switchAllowNumericOneTimePassword();
|
|
gFFI.serverModel.updatePasswordModel();
|
|
} else if (value == kUsePermanentPassword ||
|
|
value == kUseTemporaryPassword ||
|
|
value == kUseBothPasswords) {
|
|
callback() {
|
|
bind.mainSetOption(key: kOptionVerificationMethod, value: value);
|
|
gFFI.serverModel.updatePasswordModel();
|
|
}
|
|
|
|
if (value == kUsePermanentPassword &&
|
|
(await bind.mainGetCommon(key: "permanent-password-set")) !=
|
|
"true") {
|
|
if (isChangePermanentPasswordDisabled()) {
|
|
callback();
|
|
return;
|
|
}
|
|
setPasswordDialog(notEmptyCallback: callback);
|
|
} else {
|
|
callback();
|
|
}
|
|
} else if (value.startsWith("AcceptSessionsVia")) {
|
|
value = value.substring("AcceptSessionsVia".length);
|
|
if (value == "Password") {
|
|
gFFI.serverModel.setApproveMode('password');
|
|
} else if (value == "Click") {
|
|
gFFI.serverModel.setApproveMode('click');
|
|
} else {
|
|
gFFI.serverModel.setApproveMode(defaultOptionApproveMode);
|
|
}
|
|
}
|
|
})
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return actions[0];
|
|
}
|
|
}
|
|
|
|
class _ServerPageState extends State<ServerPage> {
|
|
Timer? _updateTimer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_updateTimer = periodic_immediate(const Duration(seconds: 3), () async {
|
|
await gFFI.serverModel.fetchID();
|
|
});
|
|
gFFI.serverModel.checkAndroidPermission();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_updateTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
checkService();
|
|
return ChangeNotifierProvider.value(
|
|
value: gFFI.serverModel,
|
|
child: Consumer<ServerModel>(
|
|
builder: (context, serverModel, child) => SingleChildScrollView(
|
|
controller: gFFI.serverModel.controller,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
buildPresetPasswordWarningMobile(),
|
|
gFFI.serverModel.isStart
|
|
? ServerInfo()
|
|
: ServiceNotRunningNotification(),
|
|
const ConnectionManager(),
|
|
const PermissionChecker(),
|
|
SizedBox.fromSize(size: const Size(0, 15.0)),
|
|
],
|
|
),
|
|
),
|
|
)));
|
|
}
|
|
}
|
|
|
|
void checkService() async {
|
|
gFFI.invokeMethod("check_service");
|
|
}
|
|
|
|
class ServiceNotRunningNotification extends StatelessWidget {
|
|
ServiceNotRunningNotification({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final serverModel = Provider.of<ServerModel>(context);
|
|
|
|
return PaddingCard(
|
|
title: translate("Service is not running"),
|
|
titleIcon:
|
|
const Icon(Icons.warning_amber_sharp, color: Colors.redAccent),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(translate("android_start_service_tip"),
|
|
style:
|
|
const TextStyle(fontSize: 12, color: MyTheme.darkGray))
|
|
.marginOnly(bottom: 8),
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.play_arrow),
|
|
onPressed: () {
|
|
if (gFFI.userModel.userName.value.isEmpty &&
|
|
bind.mainGetLocalOption(key: "show-scam-warning") !=
|
|
"N") {
|
|
showScamWarning(context, serverModel);
|
|
} else {
|
|
serverModel.toggleService();
|
|
}
|
|
},
|
|
label: Text(translate("Start service")))
|
|
],
|
|
));
|
|
}
|
|
}
|
|
|
|
class ScamWarningDialog extends StatefulWidget {
|
|
final ServerModel serverModel;
|
|
|
|
ScamWarningDialog({required this.serverModel});
|
|
|
|
@override
|
|
ScamWarningDialogState createState() => ScamWarningDialogState();
|
|
}
|
|
|
|
class ScamWarningDialogState extends State<ScamWarningDialog> {
|
|
int _countdown = bind.isCustomClient() ? 0 : 12;
|
|
bool show_warning = false;
|
|
late Timer _timer;
|
|
late ServerModel _serverModel;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_serverModel = widget.serverModel;
|
|
startCountdown();
|
|
}
|
|
|
|
void startCountdown() {
|
|
const oneSecond = Duration(seconds: 1);
|
|
_timer = Timer.periodic(oneSecond, (timer) {
|
|
setState(() {
|
|
_countdown--;
|
|
if (_countdown <= 0) {
|
|
timer.cancel();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isButtonLocked = _countdown > 0;
|
|
|
|
return AlertDialog(
|
|
content: ClipRRect(
|
|
borderRadius: BorderRadius.circular(20.0),
|
|
child: SingleChildScrollView(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topRight,
|
|
end: Alignment.bottomLeft,
|
|
colors: [
|
|
Color(0xffe242bc),
|
|
Color(0xfff4727c),
|
|
],
|
|
),
|
|
),
|
|
padding: EdgeInsets.all(25.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.warning_amber_sharp,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(width: 10),
|
|
Text(
|
|
translate("Warning"),
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 20.0,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 20),
|
|
Center(
|
|
child: Image.asset(
|
|
'assets/scam.png',
|
|
width: 180,
|
|
),
|
|
),
|
|
SizedBox(height: 18),
|
|
Text(
|
|
translate("scam_title"),
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 22.0,
|
|
),
|
|
),
|
|
SizedBox(height: 18),
|
|
Text(
|
|
"${translate("scam_text1")}\n\n${translate("scam_text2")}\n",
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16.0,
|
|
),
|
|
),
|
|
Row(
|
|
children: <Widget>[
|
|
Checkbox(
|
|
value: show_warning,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
show_warning = value!;
|
|
});
|
|
},
|
|
),
|
|
Text(
|
|
translate("Don't show again"),
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 15.0,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
Container(
|
|
constraints: BoxConstraints(maxWidth: 150),
|
|
child: ElevatedButton(
|
|
onPressed: isButtonLocked
|
|
? null
|
|
: () {
|
|
Navigator.of(context).pop();
|
|
_serverModel.toggleService();
|
|
if (show_warning) {
|
|
bind.mainSetLocalOption(
|
|
key: "show-scam-warning", value: "N");
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.blueAccent,
|
|
),
|
|
child: Text(
|
|
isButtonLocked
|
|
? "${translate("I Agree")} (${_countdown}s)"
|
|
: translate("I Agree"),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 13.0,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 15),
|
|
Container(
|
|
constraints: BoxConstraints(maxWidth: 150),
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.blueAccent,
|
|
),
|
|
child: Text(
|
|
translate("Decline"),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 13.0,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
contentPadding: EdgeInsets.all(0.0),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ServerInfo extends StatelessWidget {
|
|
final model = gFFI.serverModel;
|
|
final emptyController = TextEditingController(text: "-");
|
|
|
|
ServerInfo({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final serverModel = Provider.of<ServerModel>(context);
|
|
|
|
const Color colorPositive = Colors.green;
|
|
const Color colorNegative = Colors.red;
|
|
const double iconMarginRight = 15;
|
|
const double iconSize = 24;
|
|
const TextStyle textStyleHeading = TextStyle(
|
|
fontSize: 16.0, fontWeight: FontWeight.bold, color: Colors.grey);
|
|
const TextStyle textStyleValue =
|
|
TextStyle(fontSize: 25.0, fontWeight: FontWeight.bold);
|
|
|
|
void copyToClipboard(String value) {
|
|
Clipboard.setData(ClipboardData(text: value));
|
|
showToast(translate('Copied'));
|
|
}
|
|
|
|
Widget ConnectionStateNotification() {
|
|
if (serverModel.connectStatus == -1) {
|
|
return Row(children: [
|
|
const Icon(Icons.warning_amber_sharp,
|
|
color: colorNegative, size: iconSize)
|
|
.marginOnly(right: iconMarginRight),
|
|
Expanded(child: Text(translate('not_ready_status')))
|
|
]);
|
|
} else if (serverModel.connectStatus == 0) {
|
|
return Row(children: [
|
|
SizedBox(width: 20, height: 20, child: CircularProgressIndicator())
|
|
.marginOnly(left: 4, right: iconMarginRight),
|
|
Expanded(child: Text(translate('connecting_status')))
|
|
]);
|
|
} else {
|
|
return Row(children: [
|
|
const Icon(Icons.check, color: colorPositive, size: iconSize)
|
|
.marginOnly(right: iconMarginRight),
|
|
Expanded(child: Text(translate('Ready')))
|
|
]);
|
|
}
|
|
}
|
|
|
|
final showOneTime = serverModel.approveMode != 'click' &&
|
|
serverModel.verificationMethod != kUsePermanentPassword;
|
|
return PaddingCard(
|
|
title: translate('Your Device'),
|
|
child: Column(
|
|
// ID
|
|
children: [
|
|
Row(children: [
|
|
const Icon(Icons.perm_identity,
|
|
color: Colors.grey, size: iconSize)
|
|
.marginOnly(right: iconMarginRight),
|
|
Text(
|
|
translate('ID'),
|
|
style: textStyleHeading,
|
|
)
|
|
]),
|
|
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
|
Text(
|
|
model.serverId.value.text,
|
|
style: textStyleValue,
|
|
),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
icon: Icon(Icons.copy_outlined),
|
|
onPressed: () {
|
|
copyToClipboard(model.serverId.value.text.trim());
|
|
})
|
|
]).marginOnly(left: 39, bottom: 10),
|
|
// Password
|
|
Row(children: [
|
|
const Icon(Icons.lock_outline, color: Colors.grey, size: iconSize)
|
|
.marginOnly(right: iconMarginRight),
|
|
Text(
|
|
translate('One-time Password'),
|
|
style: textStyleHeading,
|
|
)
|
|
]),
|
|
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
|
Text(
|
|
!showOneTime ? '-' : model.serverPasswd.value.text,
|
|
style: textStyleValue,
|
|
),
|
|
!showOneTime
|
|
? SizedBox.shrink()
|
|
: Row(children: [
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: () => bind.mainUpdateTemporaryPassword()),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
icon: Icon(Icons.copy_outlined),
|
|
onPressed: () {
|
|
copyToClipboard(
|
|
model.serverPasswd.value.text.trim());
|
|
})
|
|
])
|
|
]).marginOnly(left: 40, bottom: 15),
|
|
ConnectionStateNotification()
|
|
],
|
|
));
|
|
}
|
|
}
|
|
|
|
class PermissionChecker extends StatefulWidget {
|
|
const PermissionChecker({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<PermissionChecker> createState() => _PermissionCheckerState();
|
|
}
|
|
|
|
class _PermissionCheckerState extends State<PermissionChecker> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final serverModel = Provider.of<ServerModel>(context);
|
|
final hasAudioPermission = androidVersion >= 30;
|
|
final hideStopService = isAndroid &&
|
|
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
|
final allowPermChangeInAcceptWindow = option2bool(
|
|
kOptionEnablePermChangeInAcceptWindow,
|
|
bind.mainGetBuildinOption(
|
|
key: kOptionEnablePermChangeInAcceptWindow,
|
|
));
|
|
final permissionChangeLocked = isAndroid &&
|
|
serverModel.clients.any((c) => !c.disconnected) &&
|
|
!allowPermChangeInAcceptWindow;
|
|
return PaddingCard(
|
|
title: translate("Permissions"),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
serverModel.mediaOk && !hideStopService
|
|
? ElevatedButton.icon(
|
|
style: ButtonStyle(
|
|
backgroundColor:
|
|
MaterialStateProperty.all(Colors.red)),
|
|
icon: const Icon(Icons.stop),
|
|
onPressed: serverModel.toggleService,
|
|
label: Text(translate("Stop service")))
|
|
.marginOnly(bottom: 8)
|
|
: SizedBox.shrink(),
|
|
if (!hideStopService || !serverModel.mediaOk)
|
|
PermissionRow(
|
|
translate("Screen Capture"),
|
|
serverModel.mediaOk,
|
|
!serverModel.mediaOk &&
|
|
gFFI.userModel.userName.value.isEmpty &&
|
|
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
|
? () => showScamWarning(context, serverModel)
|
|
: serverModel.toggleService),
|
|
PermissionRow(
|
|
translate("Input Control"),
|
|
serverModel.inputOk,
|
|
serverModel.toggleInput,
|
|
),
|
|
PermissionRow(
|
|
translate("Transfer file"),
|
|
serverModel.fileOk,
|
|
serverModel.toggleFile,
|
|
enabled: !permissionChangeLocked,
|
|
),
|
|
hasAudioPermission
|
|
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
|
serverModel.toggleAudio,
|
|
enabled: !permissionChangeLocked)
|
|
: Row(children: [
|
|
Icon(Icons.info_outline).marginOnly(right: 15),
|
|
Expanded(
|
|
child: Text(
|
|
translate("android_version_audio_tip"),
|
|
style: const TextStyle(color: MyTheme.darkGray),
|
|
))
|
|
]),
|
|
PermissionRow(
|
|
translate("Enable clipboard"),
|
|
serverModel.clipboardOk,
|
|
serverModel.toggleClipboard,
|
|
enabled: !permissionChangeLocked,
|
|
),
|
|
]));
|
|
}
|
|
}
|
|
|
|
class PermissionRow extends StatelessWidget {
|
|
const PermissionRow(this.name, this.isOk, this.onPressed,
|
|
{Key? key, this.enabled = true})
|
|
: super(key: key);
|
|
|
|
final String name;
|
|
final bool isOk;
|
|
final VoidCallback onPressed;
|
|
final bool enabled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SwitchListTile(
|
|
visualDensity: VisualDensity.compact,
|
|
contentPadding: EdgeInsets.all(0),
|
|
title: Text(name),
|
|
value: isOk,
|
|
onChanged: enabled
|
|
? (bool value) {
|
|
onPressed();
|
|
}
|
|
: null);
|
|
}
|
|
}
|
|
|
|
class ConnectionManager extends StatelessWidget {
|
|
const ConnectionManager({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final serverModel = Provider.of<ServerModel>(context);
|
|
return Column(
|
|
children: serverModel.clients
|
|
.map((client) => PaddingCard(
|
|
title: translate(
|
|
client.isFileTransfer ? "Transfer file" : "Share screen"),
|
|
titleIcon: client.isFileTransfer
|
|
? Icon(Icons.folder_outlined)
|
|
: Icon(Icons.mobile_screen_share),
|
|
child: Column(children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(child: ClientInfo(client)),
|
|
Expanded(
|
|
flex: -1,
|
|
child: client.isFileTransfer || !client.authorized
|
|
? const SizedBox.shrink()
|
|
: IconButton(
|
|
onPressed: () {
|
|
gFFI.chatModel.changeCurrentKey(
|
|
MessageKey(client.peerId, client.id));
|
|
final bar = navigationBarKey.currentWidget;
|
|
if (bar != null) {
|
|
bar as BottomNavigationBar;
|
|
bar.onTap!(1);
|
|
}
|
|
},
|
|
icon: unreadTopRightBuilder(
|
|
client.unreadChatMessageCount)))
|
|
],
|
|
),
|
|
client.authorized
|
|
? const SizedBox.shrink()
|
|
: Text(
|
|
translate("android_new_connection_tip"),
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
).marginOnly(bottom: 5),
|
|
client.authorized
|
|
? _buildDisconnectButton(client)
|
|
: _buildNewConnectionHint(serverModel, client),
|
|
if (client.incomingVoiceCall && !client.inVoiceCall)
|
|
..._buildNewVoiceCallHint(context, serverModel, client),
|
|
])))
|
|
.toList());
|
|
}
|
|
|
|
Widget _buildDisconnectButton(Client client) {
|
|
final disconnectButton = ElevatedButton.icon(
|
|
style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.red)),
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () {
|
|
bind.cmCloseConnection(connId: client.id);
|
|
gFFI.invokeMethod("cancel_notification", client.id);
|
|
},
|
|
label: Text(translate("Disconnect")),
|
|
);
|
|
final buttons = [disconnectButton];
|
|
if (client.inVoiceCall) {
|
|
buttons.insert(
|
|
0,
|
|
ElevatedButton.icon(
|
|
style: ButtonStyle(
|
|
backgroundColor: MaterialStatePropertyAll(Colors.red)),
|
|
icon: const Icon(Icons.phone),
|
|
label: Text(translate("Stop")),
|
|
onPressed: () {
|
|
bind.cmCloseVoiceCall(id: client.id);
|
|
gFFI.invokeMethod("cancel_notification", client.id);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
if (buttons.length == 1) {
|
|
return Container(
|
|
alignment: Alignment.centerRight,
|
|
child: disconnectButton,
|
|
);
|
|
} else {
|
|
return Row(
|
|
children: buttons,
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildNewConnectionHint(ServerModel serverModel, Client client) {
|
|
return Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
|
TextButton(
|
|
child: Text(translate("Dismiss")),
|
|
onPressed: () {
|
|
serverModel.sendLoginResponse(client, false);
|
|
}).marginOnly(right: 15),
|
|
if (serverModel.approveMode != 'password')
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.check),
|
|
label: Text(translate("Accept")),
|
|
onPressed: () {
|
|
serverModel.sendLoginResponse(client, true);
|
|
}),
|
|
]);
|
|
}
|
|
|
|
List<Widget> _buildNewVoiceCallHint(
|
|
BuildContext context, ServerModel serverModel, Client client) {
|
|
return [
|
|
Text(
|
|
translate("android_new_voice_call_tip"),
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
).marginOnly(bottom: 5),
|
|
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
|
TextButton(
|
|
child: Text(translate("Dismiss")),
|
|
onPressed: () {
|
|
serverModel.handleVoiceCall(client, false);
|
|
}).marginOnly(right: 15),
|
|
if (serverModel.approveMode != 'password')
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.check),
|
|
label: Text(translate("Accept")),
|
|
onPressed: () {
|
|
serverModel.handleVoiceCall(client, true);
|
|
}),
|
|
])
|
|
];
|
|
}
|
|
}
|
|
|
|
class PaddingCard extends StatelessWidget {
|
|
const PaddingCard({Key? key, required this.child, this.title, this.titleIcon})
|
|
: super(key: key);
|
|
|
|
final String? title;
|
|
final Icon? titleIcon;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final children = [child];
|
|
if (title != null) {
|
|
children.insert(
|
|
0,
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 5, 0, 8),
|
|
child: Row(
|
|
children: [
|
|
titleIcon?.marginOnly(right: 10) ?? const SizedBox.shrink(),
|
|
Expanded(
|
|
child: Text(title!,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.titleLarge
|
|
?.merge(TextStyle(fontWeight: FontWeight.bold))),
|
|
)
|
|
],
|
|
)));
|
|
}
|
|
return SizedBox(
|
|
width: double.maxFinite,
|
|
child: Card(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(13),
|
|
),
|
|
margin: const EdgeInsets.fromLTRB(12.0, 10.0, 12.0, 0),
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 15.0, horizontal: 20.0),
|
|
child: Column(
|
|
children: children,
|
|
),
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
class ClientInfo extends StatelessWidget {
|
|
final Client client;
|
|
ClientInfo(this.client);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Column(children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: -1,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: _buildAvatar(context))),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(client.name, style: const TextStyle(fontSize: 18)),
|
|
const SizedBox(width: 8),
|
|
Text(client.peerId, style: const TextStyle(fontSize: 10))
|
|
]))
|
|
],
|
|
),
|
|
]));
|
|
}
|
|
|
|
Widget _buildAvatar(BuildContext context) {
|
|
final fallback = CircleAvatar(
|
|
backgroundColor: str2color(client.name,
|
|
Theme.of(context).brightness == Brightness.light ? 255 : 150),
|
|
child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
|
|
);
|
|
return buildAvatarWidget(
|
|
avatar: client.avatar,
|
|
size: 40,
|
|
fallback: fallback,
|
|
) ??
|
|
fallback;
|
|
}
|
|
}
|
|
|
|
void androidChannelInit() {
|
|
gFFI.setMethodCallHandler((method, arguments) {
|
|
debugPrint("flutter got android msg,$method,$arguments");
|
|
try {
|
|
switch (method) {
|
|
case "start_capture":
|
|
{
|
|
gFFI.dialogManager.dismissAll();
|
|
gFFI.serverModel.updateClientState();
|
|
break;
|
|
}
|
|
case "on_state_changed":
|
|
{
|
|
var name = arguments["name"] as String;
|
|
var value = arguments["value"] as String == "true";
|
|
debugPrint("from jvm:on_state_changed,$name:$value");
|
|
gFFI.serverModel.changeStatue(name, value);
|
|
break;
|
|
}
|
|
case "on_android_permission_result":
|
|
{
|
|
var type = arguments["type"] as String;
|
|
var result = arguments["result"] as bool;
|
|
AndroidPermissionManager.complete(type, result);
|
|
break;
|
|
}
|
|
case "on_media_projection_canceled":
|
|
{
|
|
gFFI.serverModel.stopService();
|
|
break;
|
|
}
|
|
case "msgbox":
|
|
{
|
|
var type = arguments["type"] as String;
|
|
var title = arguments["title"] as String;
|
|
var text = arguments["text"] as String;
|
|
var link = (arguments["link"] ?? '') as String;
|
|
msgBox(gFFI.sessionId, type, title, text, link, gFFI.dialogManager);
|
|
break;
|
|
}
|
|
case "stop_service":
|
|
{
|
|
print(
|
|
"stop_service by kotlin, isStart:${gFFI.serverModel.isStart}");
|
|
if (gFFI.serverModel.isStart) {
|
|
gFFI.serverModel.stopService();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrintStack(label: "MethodCallHandler err:$e");
|
|
}
|
|
return "";
|
|
});
|
|
}
|
|
|
|
void showScamWarning(BuildContext context, ServerModel serverModel) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return ScamWarningDialog(serverModel: serverModel);
|
|
},
|
|
);
|
|
}
|