NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// ---------------------------------------------------------------------------
// NetDesk release signing.
// Credentials live in android/key.properties (git-ignored). Generate the
// keystore + this file with: packaging\build-android.ps1 -GenerateKeystore
// When key.properties is absent the release build falls back to DEBUG signing
// so `flutter run --release` keeps working for local development.
// ---------------------------------------------------------------------------
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
}
android {
namespace = "space.netdesk"
compileSdk = flutter.compileSdkVersion
// NetDesk ships prebuilt Rust .so under src/main/jniLibs/<abi>/libnetdesk_core.so
// (produced by cargo-ndk). Point AGP at the NDK already on disk via
// ANDROID_NDK_HOME so a Gradle build never triggers an NDK download; fall
// back to Flutter's pinned NDK version when the env var is not set.
val ndkHomeEnv = System.getenv("ANDROID_NDK_HOME")
if (ndkHomeEnv != null && file(ndkHomeEnv).exists()) {
ndkPath = ndkHomeEnv
} else {
ndkVersion = flutter.ndkVersion
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// NetDesk application id (reverse of netdesk.space).
applicationId = "space.netdesk"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = (keystoreProperties["storeFile"] as String?)?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
}
buildTypes {
release {
// Use the real upload key when key.properties is present, else debug.
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,103 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Viewer + host networking -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Host role: foreground screen-capture + unattended auto-start -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:resizeableActivity="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- The MAIN/LAUNCHER filter lives on the activity-alias below so the
launcher icon can be hidden (stealth/unattended) by disabling the
alias component at runtime. -->
</activity>
<!-- Launcher entry as an alias: disable this component (PackageManager
.setComponentEnabledSetting) to remove NetDesk from the app drawer
while the host keeps running in the background. -->
<activity-alias
android:name=".LauncherAlias"
android:targetActivity=".MainActivity"
android:exported="true"
android:enabled="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<!-- Host role: foreground service that owns the screen-capture session. -->
<service
android:name=".HostService"
android:exported="false"
android:foregroundServiceType="mediaProjection"/>
<!-- Host role: remote input injection via an accessibility service. -->
<service
android:name=".NetDeskAccessibilityService"
android:exported="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config"/>
</service>
<!-- Unattended auto-start after reboot (only acts if the user enabled it). -->
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,42 @@
package space.netdesk
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Unattended auto-start: when the device finishes booting, bring the NetDesk host
* back up (if the user enabled unattended access) so the device is reachable
* without anyone opening the app — the Android equivalent of the Windows service.
*
* The receiver only acts when unattended mode is on (stored in SharedPreferences
* by the Flutter settings screen); otherwise it does nothing.
*
* Developed by Akronlogic.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val action = intent?.action ?: return
if (action != Intent.ACTION_BOOT_COMPLETED &&
action != "android.intent.action.QUICKBOOT_POWERON"
) {
return
}
val prefs = context.getSharedPreferences("netdesk", Context.MODE_PRIVATE)
if (!prefs.getBoolean("unattended", false)) return
val svc = Intent(context, HostService::class.java).apply {
this.action = HostService.ACTION_START
}
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
context.startForegroundService(svc)
} else {
context.startService(svc)
}
} catch (_: Throwable) {
// Some OEMs block background foreground-service starts at boot; the
// user can still start the host from the app.
}
}
}
@@ -0,0 +1,311 @@
package space.netdesk
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.DisplayMetrics
import android.view.Surface
import android.view.WindowManager
import kotlin.concurrent.thread
/**
* NetDesk host foreground service: mirrors the screen through MediaProjection ->
* VirtualDisplay -> a MediaCodec H.264 (AVC) encoder and produces Annex-B frames.
*
* Right now the frames are counted (so on-device / BlueStacks testing confirms
* capture works) — the next step hands each Annex-B access unit to the host side
* of the NetDesk protocol (register by Partner ID, challenge/auth, E2E) so a
* remote viewer can connect, exactly like netdesk_host.py on Windows.
*
* Developed by Akronlogic.
*/
class HostService : Service() {
companion object {
const val CHANNEL_ID = "netdesk_host"
const val NOTIF_ID = 1001
const val ACTION_START = "space.netdesk.HOST_START"
const val ACTION_STOP = "space.netdesk.HOST_STOP"
private const val MIME = "video/avc"
/** Screen-capture grant handed over by MainActivity.onActivityResult. */
@Volatile
var projectionResultCode: Int = 0
@Volatile
var projectionData: Intent? = null
@Volatile
var running: Boolean = false
/** Encoded H.264 access units produced so far (capture liveness check). */
@Volatile
var frameCount: Long = 0
/** Capture resolution (advertised to the viewer via host_start). */
@Volatile
var captureW: Int = 0
@Volatile
var captureH: Int = 0
/** Real display resolution — viewer coords (in capture space) scale to it. */
@Volatile
var realW: Int = 0
@Volatile
var realH: Int = 0
/**
* Sink for each outbound access unit: payload[0] = keyframe flag (1/0),
* payload[1..] = Annex-B H.264 (SPS/PPS is prepended to every keyframe so
* the frame is self-contained for mid-stream joins + decoder recovery).
* MainActivity sets this to forward over the `space.netdesk/frames`
* EventChannel into Dart -> host_push_frame.
*/
@Volatile
var frameSink: ((ByteArray) -> Unit)? = null
/** Live instance, so MainActivity can ask for an on-demand keyframe. */
@Volatile
var instance: HostService? = null
}
private var projection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var encoder: MediaCodec? = null
private var inputSurface: Surface? = null
@Volatile
private var draining: Boolean = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopHost()
return START_NOT_STICKY
}
goForeground()
running = true
instance = this
startCapture()
return START_STICKY
}
private fun goForeground() {
val notif = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIF_ID,
notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
)
} else {
startForeground(NOTIF_ID, notif)
}
}
private fun startCapture() {
val data = projectionData ?: return
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val proj = mpm.getMediaProjection(projectionResultCode, data) ?: return
projection = proj
projectionData = null
// The system requires a registered callback before createVirtualDisplay.
proj.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
stopHost()
}
}, null)
val metrics = DisplayMetrics()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val b = wm.currentWindowMetrics.bounds
metrics.widthPixels = b.width()
metrics.heightPixels = b.height()
metrics.densityDpi = resources.configuration.densityDpi
} else {
@Suppress("DEPRECATION")
(getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.defaultDisplay.getRealMetrics(metrics)
}
// Cap the long edge at 1600 px for bandwidth; keep it even.
var w = metrics.widthPixels
var h = metrics.heightPixels
realW = w
realH = h
val longEdge = maxOf(w, h)
if (longEdge > 1600) {
val s = 1600.0 / longEdge
w = (w * s).toInt()
h = (h * s).toInt()
}
w = w and 1.inv()
h = h and 1.inv()
captureW = w
captureH = h
val dpi = if (metrics.densityDpi > 0) metrics.densityDpi else 320
val format = MediaFormat.createVideoFormat(MIME, w, h).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
setInteger(MediaFormat.KEY_BIT_RATE, 6_000_000)
setInteger(MediaFormat.KEY_FRAME_RATE, 30)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
}
val enc = MediaCodec.createEncoderByType(MIME)
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
inputSurface = enc.createInputSurface()
enc.start()
encoder = enc
virtualDisplay = proj.createVirtualDisplay(
"netdesk-host",
w, h, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
inputSurface, null, null
)
draining = true
thread(name = "netdesk-encoder", isDaemon = true) { drainLoop() }
}
/** Cached SPS/PPS (BUFFER_FLAG_CODEC_CONFIG), prepended to each keyframe. */
private var csd: ByteArray? = null
/** Ask the encoder for an immediate IDR (viewer keyframe request / recovery). */
fun requestKeyframe() {
try {
encoder?.setParameters(Bundle().apply {
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
})
} catch (_: Throwable) {
}
}
private fun drainLoop() {
val enc = encoder ?: return
val info = MediaCodec.BufferInfo()
while (draining) {
val idx = try {
enc.dequeueOutputBuffer(info, 20_000)
} catch (_: IllegalStateException) {
break
}
if (idx >= 0) {
val buf = enc.getOutputBuffer(idx)
if (buf != null && info.size > 0) {
buf.position(info.offset)
buf.limit(info.offset + info.size)
val bytes = ByteArray(info.size)
buf.get(bytes)
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
if (isConfig) {
// SPS/PPS — cache it, don't emit on its own.
csd = bytes
} else {
frameCount++
val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
// Self-contained keyframe = SPS/PPS || IDR (like libx264).
val au = if (isKey && csd != null) csd!! + bytes else bytes
val sink = frameSink
if (sink != null) {
val payload = ByteArray(au.size + 1)
payload[0] = if (isKey) 1 else 0
System.arraycopy(au, 0, payload, 1, au.size)
try {
sink(payload)
} catch (_: Throwable) {
}
}
}
}
enc.releaseOutputBuffer(idx, false)
}
}
}
private fun stopHost() {
running = false
draining = false
if (instance === this) instance = null
csd = null
captureW = 0
captureH = 0
try {
virtualDisplay?.release()
} catch (_: Throwable) {
}
virtualDisplay = null
try {
encoder?.stop()
encoder?.release()
} catch (_: Throwable) {
}
encoder = null
try {
inputSurface?.release()
} catch (_: Throwable) {
}
inputSurface = null
try {
projection?.stop()
} catch (_: Throwable) {
}
projection = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
override fun onDestroy() {
stopHost()
super.onDestroy()
}
private fun buildNotification(): Notification {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(
CHANNEL_ID,
"NetDesk Host",
NotificationManager.IMPORTANCE_LOW
)
ch.description = "Shown while this device can be controlled remotely."
nm.createNotificationChannel(ch)
}
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setContentTitle("NetDesk host is active")
.setContentText("This device can be controlled remotely")
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,212 @@
package space.netdesk
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.text.TextUtils
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/**
* Hosts the Flutter UI and bridges the Android "host" role to Dart over the
* `space.netdesk/host` MethodChannel: request the screen-capture permission,
* start/stop the host service, toggle the hidden launcher icon + unattended
* mode, and check / open the accessibility settings.
*
* Developed by Akronlogic.
*/
class MainActivity : FlutterActivity() {
private val channelName = "space.netdesk/host"
private val framesChannel = "space.netdesk/frames"
private val reqProjection = 7001
private var pendingResult: MethodChannel.Result? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Encoded H.264 access units flow HostService -> here -> Dart, which feeds
// them to the Rust host via host_push_frame. payload[0]=keyframe flag.
EventChannel(flutterEngine.dartExecutor.binaryMessenger, framesChannel)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
HostService.frameSink = { payload ->
mainHandler.post { events?.success(payload) }
}
}
override fun onCancel(arguments: Any?) {
HostService.frameSink = null
}
})
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"startHost" -> requestProjection(result)
"stopHost" -> {
stopHost(); result.success(true)
}
"isHostRunning" -> result.success(HostService.running)
"lastFrameCount" -> result.success(HostService.frameCount)
"hostScreenSize" -> result.success(
mapOf("w" to HostService.captureW, "h" to HostService.captureH)
)
"requestKeyframe" -> {
HostService.instance?.requestKeyframe(); result.success(true)
}
"injectTap" -> {
NetDeskAccessibilityService.instance?.tap(
sx(call.argument<Double>("x") ?: 0.0),
sy(call.argument<Double>("y") ?: 0.0)
)
result.success(true)
}
"injectSwipe" -> {
NetDeskAccessibilityService.instance?.swipe(
sx(call.argument<Double>("x1") ?: 0.0),
sy(call.argument<Double>("y1") ?: 0.0),
sx(call.argument<Double>("x2") ?: 0.0),
sy(call.argument<Double>("y2") ?: 0.0),
(call.argument<Int>("dur") ?: 60).toLong()
)
result.success(true)
}
"injectKey" -> {
NetDeskAccessibilityService.instance?.key(
call.argument<String>("code") ?: "",
call.argument<Boolean>("down") ?: false
)
result.success(true)
}
"getHostId" -> result.success(
getSharedPreferences("netdesk", Context.MODE_PRIVATE)
.getString("hostId", "")
)
"getHostPassword" -> result.success(
getSharedPreferences("netdesk", Context.MODE_PRIVATE)
.getString("hostPassword", "")
)
"setHostCreds" -> {
getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit()
.putString("hostId", call.argument<String>("id") ?: "")
.putString("hostPassword", call.argument<String>("password") ?: "")
.apply()
result.success(true)
}
"setHideIcon" -> {
setHideIcon(call.argument<Boolean>("hide") ?: false)
result.success(true)
}
"isIconHidden" -> result.success(isIconHidden())
"hasAccessibility" -> result.success(isAccessibilityEnabled())
"openAccessibilitySettings" -> {
startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
result.success(true)
}
"setUnattended" -> {
getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit()
.putBoolean("unattended", call.argument<Boolean>("on") ?: false)
.apply()
result.success(true)
}
"isUnattended" -> result.success(
getSharedPreferences("netdesk", Context.MODE_PRIVATE)
.getBoolean("unattended", false)
)
else -> result.notImplemented()
}
}
}
private fun requestProjection(result: MethodChannel.Result) {
pendingResult = result
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mpm.createScreenCaptureIntent(), reqProjection)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != reqProjection) return
if (resultCode == Activity.RESULT_OK && data != null) {
// Hand the capture token to the foreground service, which obtains the
// MediaProjection after it has gone foreground (required on API 34+).
HostService.projectionResultCode = resultCode
HostService.projectionData = data
val svc = Intent(this, HostService::class.java).apply {
action = HostService.ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(svc)
} else {
startService(svc)
}
pendingResult?.success(true)
} else {
pendingResult?.success(false)
}
pendingResult = null
}
private fun stopHost() {
val svc = Intent(this, HostService::class.java).apply {
action = HostService.ACTION_STOP
}
startService(svc)
}
/** Scale a viewer x-coord (capture space) to the real display for injection. */
private fun sx(x: Double): Float {
val cw = HostService.captureW
val rw = HostService.realW
return if (cw > 0 && rw > 0) (x * rw / cw).toFloat() else x.toFloat()
}
private fun sy(y: Double): Float {
val ch = HostService.captureH
val rh = HostService.realH
return if (ch > 0 && rh > 0) (y * rh / ch).toFloat() else y.toFloat()
}
private fun setHideIcon(hide: Boolean) {
val alias = ComponentName(this, "space.netdesk.LauncherAlias")
val state = if (hide) {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
packageManager.setComponentEnabledSetting(alias, state, PackageManager.DONT_KILL_APP)
}
private fun isIconHidden(): Boolean {
val alias = ComponentName(this, "space.netdesk.LauncherAlias")
return packageManager.getComponentEnabledSetting(alias) ==
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
private fun isAccessibilityEnabled(): Boolean {
val expected =
ComponentName(this, NetDeskAccessibilityService::class.java).flattenToString()
val enabled = Settings.Secure.getString(
contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
) ?: return false
val splitter = TextUtils.SimpleStringSplitter(':')
splitter.setString(enabled)
while (splitter.hasNext()) {
if (splitter.next().equals(expected, ignoreCase = true)) return true
}
return false
}
}
@@ -0,0 +1,84 @@
package space.netdesk
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.view.accessibility.AccessibilityEvent
/**
* Remote-input injection for the Android host role.
*
* A normal app can't inject touch events, so NetDesk (like RustDesk) uses an
* AccessibilityService: once the user enables it, [dispatchGesture] can tap,
* swipe and drag anywhere on screen. This is the skeleton; the host session
* calls [tap] / [swipe] with the coordinates a remote viewer sends.
*
* (Full keyboard/text injection and the on-lock-screen limitation are handled in
* later steps — injected input can't reach the secure lock screen, same as
* RustDesk.)
*
* Developed by Akronlogic.
*/
class NetDeskAccessibilityService : AccessibilityService() {
companion object {
@Volatile
var instance: NetDeskAccessibilityService? = null
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// The host role drives the device OUTward (injects input); it does not
// need to react to UI events, so this is intentionally empty.
}
override fun onInterrupt() {}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
/** Tap (x, y) in screen pixels. */
fun tap(x: Float, y: Float) {
val path = Path().apply { moveTo(x, y) }
val stroke = GestureDescription.StrokeDescription(path, 0, 1)
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
/** Swipe/drag from (x1, y1) to (x2, y2) over [durationMs]. */
fun swipe(x1: Float, y1: Float, x2: Float, y2: Float, durationMs: Long) {
val path = Path().apply {
moveTo(x1, y1)
lineTo(x2, y2)
}
val stroke =
GestureDescription.StrokeDescription(path, 0, durationMs.coerceAtLeast(1))
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
/**
* Map a viewer key (DOM-style code) to an Android navigation action on key
* DOWN. An AccessibilityService can't inject arbitrary key/text events (that
* needs an IME, a later step), but the global navigation actions cover the
* phone's hardware keys — the common remote-control needs.
*/
fun key(code: String, down: Boolean) {
if (!down) return
val action = when (code) {
"Escape", "BrowserBack", "GoBack" -> GLOBAL_ACTION_BACK
"Home", "MetaLeft", "MetaRight", "BrowserHome" -> GLOBAL_ACTION_HOME
"F1", "ContextMenu" -> GLOBAL_ACTION_RECENTS
"AudioVolumeUp", "AudioVolumeDown" -> return // reserved for later
else -> return
}
try {
performGlobalAction(action)
} catch (_: Throwable) {
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">NetDesk</string>
<string name="accessibility_desc">NetDesk uses this to let a remote viewer tap, swipe and type on this device while you are connected. Developed by Akronlogic.</string>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canPerformGestures="true"
android:notificationTimeout="100"
android:description="@string/accessibility_desc" />
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>