Files
rustdesk/flutter/lib/mobile/pages/server_page.dart
Michael Clark d4b06a6c5c fix: android: replace all-files access with scoped storage (#15602)
* 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>
2026-08-31 16:29:51 +08:00

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);
},
);
}