Add Android device deployment flow (#15146)

* Add Android device deployment flow

  Notify the Android Flutter UI when the server requires deployment, add a deploy dialog with API token/custom ID inputs, and reuse shared deploy logic
  for CLI and FFI

Signed-off-by: 21pages <sunboeasy@gmail.com>

* Hide Android deploy API token input

Signed-off-by: 21pages <sunboeasy@gmail.com>

* add more translations

Signed-off-by: 21pages <sunboeasy@gmail.com>

* optimize transations

Signed-off-by: 21pages <sunboeasy@gmail.com>

* Hide deploy action for outgoing-only clients

Signed-off-by: 21pages <sunboeasy@gmail.com>

* Fix deployment register throttle state reset

Signed-off-by: 21pages <sunboeasy@gmail.com>

* Move Android deploy dialog out of settings page

Signed-off-by: 21pages <sunboeasy@gmail.com>

* Use async mutex for deploy register throttle

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
21pages
2026-06-02 14:28:30 +08:00
committed by GitHub
parent 32c6e32e04
commit d99ddf6816
59 changed files with 604 additions and 66 deletions

View File

@@ -27,6 +27,7 @@ import 'common.dart';
import 'consts.dart';
import 'mobile/pages/home_page.dart';
import 'mobile/pages/server_page.dart';
import 'mobile/widgets/deploy_dialog.dart';
import 'models/platform_model.dart';
import 'package:flutter_hbb/plugin/handlers.dart'
@@ -575,6 +576,14 @@ _registerEventHandler() {
NativeUiHandler.instance.onEvent(evt);
});
}
if (isAndroid) {
platformFFI.registerEventHandler(
'android_needs_deploy', 'android_needs_deploy', (_) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDeployPromptDialog();
});
});
}
}
Widget keyListenerBuilder(BuildContext context, Widget? child) {

View File

@@ -17,6 +17,7 @@ import '../../common/widgets/login.dart';
import '../../consts.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../widgets/deploy_dialog.dart';
import '../widgets/dialog.dart';
import 'home_page.dart';
import 'scan_page.dart';
@@ -728,6 +729,13 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onPressed: (context) {
changeSocks5Proxy();
}),
if (isAndroid && !bind.isOutgoingOnly())
SettingsTile(
title: Text(translate('Deploy')),
leading: Icon(Icons.cloud_upload),
onPressed: (context) {
showDeployDialog();
}),
if (!disabledSettings && !_hideNetwork && !_hideWebSocket)
SettingsTile.switchTile(
title: Text(translate('Use WebSocket')),

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
const _deployDialogTag = 'android-deploy-device';
void showDeployPromptDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
gFFI.dialogManager.show<bool>((setState, close, context) {
submit() => close(true);
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Text(translate("server_requires_deployment_tip")),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
}, tag: _deployDialogTag).then((deploy) {
if (deploy == true) {
showDeployDialog();
}
});
}
void showDeployDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
final tokenController = TextEditingController();
final idController = TextEditingController();
var errorText = "";
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
if (isInProgress) return;
final token = tokenController.text.trim();
if (token.isEmpty) {
setState(() {
errorText = translate("token is required!");
});
return;
}
setState(() {
errorText = "";
isInProgress = true;
});
String res;
try {
res = await bind.mainDeployDevice(
token: token, id: idController.text.trim());
} catch (e) {
setState(() {
errorText = translate(e.toString());
isInProgress = false;
});
return;
}
if (res.isEmpty) {
close();
await gFFI.serverModel.fetchID();
showToast(translate("Successful"));
} else {
setState(() {
errorText = translate(res.toString());
isInProgress = false;
});
}
}
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: tokenController,
decoration: InputDecoration(labelText: translate("API Token")),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofocus: true,
).workaroundFreezeLinuxMint(),
TextField(
controller: idController,
decoration:
InputDecoration(labelText: translate("Custom ID (optional)")),
).workaroundFreezeLinuxMint(),
if (errorText.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
errorText,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
).paddingOnly(top: 8),
),
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
],
),
actions: [
dialogButton("Cancel",
onPressed: isInProgress ? null : close, isOutline: true),
dialogButton("OK", onPressed: isInProgress ? null : submit),
],
onSubmit: submit,
onCancel: isInProgress ? null : close,
);
}, tag: _deployDialogTag);
}

View File

@@ -2034,7 +2034,14 @@ class RustdeskImpl {
}
String mainResolveAvatarUrl({required String avatar, dynamic hint}) {
return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar;
return js.context.callMethod(
'getByName', ['resolve_avatar_url', avatar])?.toString() ??
avatar;
}
Future<String> mainDeployDevice(
{required String token, required String id, dynamic hint}) {
throw UnimplementedError("mainDeployDevice");
}
void dispose() {}