feat: floating window shows live remote screen

- FloatingWindowService now runs as foreground service (prevents process kill)
- Dart pushes frames at 10fps to native via method channel
- Frames resized to 160x160 and decoded as Bitmap in Kotlin
- Added update_floating_frame method channel handler
- Connection stays alive when activating floating window mode
This commit is contained in:
Artur Puig 2026-06-21 00:30:32 +02:00
commit 0f4d0f5351
9 changed files with 260 additions and 27 deletions

View file

@ -117,7 +117,7 @@ android {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.release
signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules'
}
}

View file

@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@ -92,7 +93,12 @@
<service
android:name=".FloatingWindowService"
android:enabled="true" />
android:enabled="true"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains remote desktop connection while overlay is active" />
</service>
<!--
Don't delete the meta-data below.

View file

@ -1,11 +1,14 @@
package com.carriez.flutter_hbb
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.PixelFormat
import android.graphics.drawable.BitmapDrawable
@ -25,8 +28,10 @@ import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.ImageView
import android.widget.PopupMenu
import androidx.core.app.NotificationCompat
import com.caverock.androidsvg.SVG
import ffi.FFI
import java.nio.ByteBuffer
import kotlin.math.abs
class FloatingWindowService : Service(), View.OnTouchListener {
@ -46,6 +51,7 @@ class FloatingWindowService : Service(), View.OnTouchListener {
companion object {
private val logTag = "floatingService"
private const val NOTIFY_ID_FLOATING = 200
private var firstCreate = true
private var viewWidth = 120
private var viewHeight = 120
@ -57,6 +63,13 @@ class FloatingWindowService : Service(), View.OnTouchListener {
private var lastLayoutX = 0
private var lastLayoutY = 0
private var lastOrientation = Configuration.ORIENTATION_UNDEFINED
var instance: FloatingWindowService? = null
private set
fun updateFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
instance?.updateFrameInternal(rgbaBytes, width, height)
}
}
override fun onBind(intent: Intent): IBinder? {
@ -65,6 +78,8 @@ class FloatingWindowService : Service(), View.OnTouchListener {
override fun onCreate() {
super.onCreate()
instance = this
startForegroundWithNotification()
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
try {
if (firstCreate) {
@ -82,10 +97,70 @@ class FloatingWindowService : Service(), View.OnTouchListener {
override fun onDestroy() {
super.onDestroy()
instance = null
if (viewCreated) {
windowManager.removeView(floatingView)
}
handler.removeCallbacks(runnable)
stopForeground(STOP_FOREGROUND_REMOVE)
}
private fun updateFrameInternal(rgbaBytes: ByteArray, width: Int, height: Int) {
try {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(rgbaBytes))
runOnUiThread {
floatingView.setImageBitmap(bitmap)
floatingView.alpha = viewTransparency * 1f
// Ensure full width when showing frame content
if (layoutParams.width == viewWidth / 2) {
layoutParams.width = viewWidth
windowManager.updateViewLayout(floatingView, layoutParams)
}
}
} catch (e: Exception) {
Log.e(logTag, "updateFrame failed: $e")
}
}
private fun runOnUiThread(runnable: Runnable) {
Handler(Looper.getMainLooper()).post(runnable)
}
private fun startForegroundWithNotification() {
val channelId = "rustdesk_floating"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
translate("RustDesk"),
NotificationManager.IMPORTANCE_LOW
).apply {
description = translate("RustDesk")
lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.mipmap.ic_stat_logo)
.setContentTitle(translate("RustDesk"))
.setContentText(translate("Service is running"))
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
startForeground(NOTIFY_ID_FLOATING, notification)
}
@SuppressLint("ClickableViewAccessibility")
@ -312,7 +387,7 @@ class FloatingWindowService : Service(), View.OnTouchListener {
}
val idStopService = 2
val hideStopService = FFI.getBuildinOption("hide-stop-service") == "Y"
if (!hideStopService) {
if (!hideStopService && MainService.isReady) {
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
}
popupMenu.setOnMenuItemClickListener { menuItem ->

View file

@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
import ffi.FFI
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@ -39,6 +40,7 @@ class MainActivity : FlutterActivity() {
companion object {
var flutterMethodChannel: MethodChannel? = null
private var _rdClipboardManager: RdClipboardManager? = null
private var hasActiveRemoteSession = false
val rdClipboardManager: RdClipboardManager?
get() = _rdClipboardManager;
}
@ -230,6 +232,34 @@ class MainActivity : FlutterActivity() {
rdClipboardManager?.syncClipboard(true)
result.success(true)
}
"set_active_remote_session" -> {
hasActiveRemoteSession = call.arguments == true
Log.d(logTag, "set_active_remote_session: $hasActiveRemoteSession")
result.success(true)
}
"update_floating_frame" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
val bytes = args["bytes"] as? ByteArray
val width = args["width"] as? Int ?: 120
val height = args["height"] as? Int ?: 120
if (bytes != null) {
FloatingWindowService.updateFrame(bytes, width, height)
}
}
result.success(true)
}
"move_to_floating_window" -> {
val disableFloatingWindow = FFI.getLocalOption("disable-floating-window") == "Y"
val canFloat = !disableFloatingWindow &&
(MainService.isReady || hasActiveRemoteSession) &&
XXPermissions.isGranted(context, Manifest.permission.SYSTEM_ALERT_WINDOW)
if (canFloat) {
startService(Intent(this, FloatingWindowService::class.java))
moveTaskToBack(true)
}
result.success(canFloat)
}
GET_START_ON_BOOT_OPT -> {
val prefs = getSharedPreferences(KEY_SHARED_PREFERENCES, MODE_PRIVATE)
result.success(prefs.getBoolean(KEY_START_ON_BOOT_OPT, false))
@ -402,7 +432,7 @@ class MainActivity : FlutterActivity() {
override fun onStop() {
super.onStop()
val disableFloatingWindow = FFI.getLocalOption("disable-floating-window") == "Y"
if (!disableFloatingWindow && MainService.isReady) {
if (!disableFloatingWindow && (MainService.isReady || hasActiveRemoteSession)) {
startService(Intent(this, FloatingWindowService::class.java))
}
}

View file

@ -12,6 +12,15 @@ class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
Log.d(TAG, "App start")
FFI.onAppStart(applicationContext)
try {
FFI.onAppStart(applicationContext)
Log.d(TAG, "FFI.onAppStart succeeded")
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "Failed to load native library", e)
throw e
} catch (e: Exception) {
Log.e(TAG, "FFI.onAppStart failed", e)
throw e
}
}
}

View file

@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx1024M
org.gradle.jvmargs=-Xmx4g
android.useAndroidX=true
android.enableJetifier=true
org.gradle.daemon=false

View file

@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"

View file

@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -66,6 +68,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
Orientation? _currentOrientation;
final _uniqueKey = UniqueKey();
Timer? _iosKeyboardWorkaroundTimer;
Timer? _floatingFrameTimer;
final _blockableOverlayState = BlockableOverlayState();
@ -94,6 +97,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
void initState() {
super.initState();
gFFI.ffiModel.updateEventListener(sessionId, widget.id);
if (isAndroid) {
unawaited(gFFI.invokeMethod("set_active_remote_session", true));
}
gFFI.start(
widget.id,
password: widget.password,
@ -151,6 +157,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
// "Connecting...". Dispatching it here makes teardown happen synchronously on
// pop; the `sessionClose` in `gFFI.close()` becomes a no-op once removed.
unawaited(bind.sessionClose(sessionId: sessionId));
if (isAndroid) {
unawaited(gFFI.invokeMethod("set_active_remote_session", false));
}
// https://github.com/flutter/flutter/issues/64935
super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
@ -166,6 +175,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
await gFFI.close();
_timer?.cancel();
_iosKeyboardWorkaroundTimer?.cancel();
_floatingFrameTimer?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
@ -181,6 +191,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_stopFloatingFramePush();
trySyncClipboard();
}
}
@ -433,6 +444,57 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
});
}
Future<void> moveToFloatingWindow() async {
final ok = await gFFI.invokeMethod("move_to_floating_window");
if (ok != true) {
showToast('No permission');
} else {
_startFloatingFramePush();
}
}
void _startFloatingFramePush() {
_floatingFrameTimer?.cancel();
_floatingFrameTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
_sendFloatingFrame();
});
}
void _stopFloatingFramePush() {
_floatingFrameTimer?.cancel();
_floatingFrameTimer = null;
}
Future<void> _sendFloatingFrame() async {
try {
final image = gFFI.imageModel.image;
if (image == null) return;
const targetW = 160;
const targetH = 160;
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
Rect.fromLTWH(0, 0, targetW.toDouble(), targetH.toDouble()),
Paint(),
);
final picture = recorder.endRecording();
final resized = await picture.toImage(targetW, targetH);
final byteData = await resized.toByteData(format: ui.ImageByteFormat.rawRgba);
if (byteData != null) {
await gFFI.invokeMethod("update_floating_frame", {
'bytes': byteData.buffer.asUint8List(),
'width': targetW,
'height': targetH,
});
}
resized.dispose();
} catch (e) {
debugPrint('_sendFloatingFrame error: $e');
}
}
Widget _bottomWidget() => _showGestureHelp
? getGestureHelp()
: (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
@ -575,7 +637,13 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
setState(() => _showEdit = false);
showOptions(context, widget.id, gFFI.dialogManager);
},
)
),
if (isAndroid)
IconButton(
color: Colors.white,
icon: const Icon(Icons.picture_in_picture_alt),
onPressed: moveToFloatingWindow,
)
] +
(isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard
? []

View file

@ -58,10 +58,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.11.0"
auto_size_text:
dependency: "direct main"
description:
@ -90,10 +90,10 @@ packages:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.1"
bot_toast:
dependency: "direct main"
description:
@ -417,6 +417,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.3"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
ffi:
dependency: "direct main"
description:
@ -644,6 +652,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
@ -849,6 +862,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev"
source: hosted
version: "10.0.5"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
@ -877,10 +914,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
@ -1218,10 +1255,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.0"
sqflite:
dependency: "direct main"
description:
@ -1242,18 +1279,18 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.2"
stream_transform:
dependency: transitive
description:
@ -1266,10 +1303,10 @@ packages:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.2.0"
synchronized:
dependency: transitive
description:
@ -1282,18 +1319,18 @@ packages:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.2"
texture_rgba_renderer:
dependency: "direct main"
description:
@ -1473,7 +1510,7 @@ packages:
source: hosted
version: "1.1.16"
vector_math:
dependency: transitive
dependency: "direct main"
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
@ -1528,6 +1565,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.0+2"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.5"
wakelock_plus:
dependency: "direct main"
description: