From 6a5222733ef710beb08c1472fe7a481287c65733 Mon Sep 17 00:00:00 2001 From: Gaurav Ujjwal Date: Wed, 5 Jun 2024 18:06:01 +0530 Subject: [PATCH] Implement Wake-on-LAN support Re: #218 --- .../com.gaurav.avnc.model.db.MainDb/6.json | 229 ++++++++++++++++++ .../com/gaurav/avnc/model/ServerProfile.kt | 12 + .../java/com/gaurav/avnc/model/db/MainDb.kt | 3 +- .../com/gaurav/avnc/ui/home/ProfileEditor.kt | 12 + .../com/gaurav/avnc/ui/vnc/VncActivity.kt | 3 +- .../java/com/gaurav/avnc/util/WakeOnLAN.kt | 68 ++++++ .../gaurav/avnc/viewmodel/EditorViewModel.kt | 2 + .../com/gaurav/avnc/viewmodel/VncViewModel.kt | 11 + app/src/main/res/drawable/ic_wake_on_lan.xml | 21 ++ app/src/main/res/layout/activity_vnc.xml | 2 +- .../fragment_profile_editor_advanced.xml | 36 ++- app/src/main/res/values/strings.xml | 3 + 12 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 app/roomSchema/com.gaurav.avnc.model.db.MainDb/6.json create mode 100644 app/src/main/java/com/gaurav/avnc/util/WakeOnLAN.kt create mode 100644 app/src/main/res/drawable/ic_wake_on_lan.xml diff --git a/app/roomSchema/com.gaurav.avnc.model.db.MainDb/6.json b/app/roomSchema/com.gaurav.avnc.model.db.MainDb/6.json new file mode 100644 index 0000000..995c22d --- /dev/null +++ b/app/roomSchema/com.gaurav.avnc.model.db.MainDb/6.json @@ -0,0 +1,229 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "7d25813fc2619854164fe3d395f2aa28", + "entities": [ + { + "tableName": "profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `serverTypeHint` TEXT NOT NULL DEFAULT '', `flags` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `screenOrientation` TEXT NOT NULL DEFAULT 'auto', `useCount` INTEGER NOT NULL, `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `resizeRemoteDesktop` INTEGER NOT NULL DEFAULT 0, `enableWol` INTEGER NOT NULL DEFAULT 0, `wolMAC` TEXT NOT NULL DEFAULT '', `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "ID", + "columnName": "ID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityType", + "columnName": "securityType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelType", + "columnName": "channelType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "colorLevel", + "columnName": "colorLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imageQuality", + "columnName": "imageQuality", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useRawEncoding", + "columnName": "useRawEncoding", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "zoom1", + "columnName": "zoom1", + "affinity": "REAL", + "notNull": true, + "defaultValue": "1.0" + }, + { + "fieldPath": "zoom2", + "columnName": "zoom2", + "affinity": "REAL", + "notNull": true, + "defaultValue": "1.0" + }, + { + "fieldPath": "viewOnly", + "columnName": "viewOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useLocalCursor", + "columnName": "useLocalCursor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverTypeHint", + "columnName": "serverTypeHint", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gestureStyle", + "columnName": "gestureStyle", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'auto'" + }, + { + "fieldPath": "screenOrientation", + "columnName": "screenOrientation", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'auto'" + }, + { + "fieldPath": "useCount", + "columnName": "useCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useRepeater", + "columnName": "useRepeater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "idOnRepeater", + "columnName": "idOnRepeater", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resizeRemoteDesktop", + "columnName": "resizeRemoteDesktop", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enableWol", + "columnName": "enableWol", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wolMAC", + "columnName": "wolMAC", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sshHost", + "columnName": "sshHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sshPort", + "columnName": "sshPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sshUsername", + "columnName": "sshUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sshAuthType", + "columnName": "sshAuthType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sshPassword", + "columnName": "sshPassword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sshPrivateKey", + "columnName": "sshPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sshPrivateKeyPassword", + "columnName": "sshPrivateKeyPassword", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "ID" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d25813fc2619854164fe3d395f2aa28')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/gaurav/avnc/model/ServerProfile.kt b/app/src/main/java/com/gaurav/avnc/model/ServerProfile.kt index 4844764..b9cc65b 100644 --- a/app/src/main/java/com/gaurav/avnc/model/ServerProfile.kt +++ b/app/src/main/java/com/gaurav/avnc/model/ServerProfile.kt @@ -165,6 +165,18 @@ data class ServerProfile( @ColumnInfo(defaultValue = "0") var resizeRemoteDesktop: Boolean = false, + /** + * Enable Wake-on-LAN + */ + @ColumnInfo(defaultValue = "0") + var enableWol: Boolean = false, + + /** + * MAC address for Wake-on-LAN + */ + @ColumnInfo(defaultValue = "") + var wolMAC: String = "", + /** * These values are used for SSH Tunnel */ diff --git a/app/src/main/java/com/gaurav/avnc/model/db/MainDb.kt b/app/src/main/java/com/gaurav/avnc/model/db/MainDb.kt index 5990106..726e419 100644 --- a/app/src/main/java/com/gaurav/avnc/model/db/MainDb.kt +++ b/app/src/main/java/com/gaurav/avnc/model/db/MainDb.kt @@ -23,6 +23,7 @@ import com.gaurav.avnc.model.ServerProfile AutoMigration(from = 2, to = 3, spec = MainDb.MigrationSpec2to3::class), // in v2.1.0 AutoMigration(from = 3, to = 4), // in v2.2.2 AutoMigration(from = 4, to = 5, spec = MainDb.MigrationSpec4to5::class), // in v2.3.0 + AutoMigration(from = 5, to = 6), // in v2.x.x ]) abstract class MainDb : RoomDatabase() { abstract val serverProfileDao: ServerProfileDao @@ -31,7 +32,7 @@ abstract class MainDb : RoomDatabase() { /** * Current database version */ - const val VERSION = 5 + const val VERSION = 6 private var instance: MainDb? = null diff --git a/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditor.kt b/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditor.kt index 1e1cd04..96b2e33 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditor.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditor.kt @@ -32,6 +32,7 @@ import com.gaurav.avnc.databinding.FragmentProfileEditorBinding import com.gaurav.avnc.model.ServerProfile import com.gaurav.avnc.util.MsgDialog import com.gaurav.avnc.util.OpenableDocument +import com.gaurav.avnc.util.parseMacAddress import com.gaurav.avnc.viewmodel.EditorViewModel import com.gaurav.avnc.viewmodel.HomeViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -171,6 +172,7 @@ class AdvancedProfileEditor : Fragment() { setupHelpButton(binding.keyCompatModeHelpBtn, R.string.title_key_compat_mode, R.string.msg_key_compat_mode_help) setupHelpButton(binding.buttonUpDelayHelpBtn, R.string.title_button_up_delay, R.string.msg_button_up_delay_help) + setupHelpButton(binding.wolHelpBtn, R.string.title_enable_wol, R.string.msg_wake_on_lan_help) return binding.root } @@ -195,6 +197,9 @@ class AdvancedProfileEditor : Fragment() { if (binding.useRepeater.isChecked) result = result and validateNotEmpty(binding.idOnRepeater) + if (binding.wol.isChecked) + result = result and (validateNotEmpty(binding.wolMac) && validateMACAddress()) + if (binding.useSshTunnel.isChecked) { result = result and validateNotEmpty(binding.sshHost) and @@ -206,6 +211,13 @@ class AdvancedProfileEditor : Fragment() { return result } + private fun validateMACAddress(): Boolean { + if (runCatching { parseMacAddress(binding.wolMac.text.toString()) }.isFailure) { + binding.wolMac.error = "Invalid MAC address" + return false + } + return true + } private fun validatePrivateKey(): Boolean { if (binding.sshAuthTypeKey.isChecked && viewModel.hasSshPrivateKey.value != true) { diff --git a/app/src/main/java/com/gaurav/avnc/ui/vnc/VncActivity.kt b/app/src/main/java/com/gaurav/avnc/ui/vnc/VncActivity.kt index d126725..136f949 100644 --- a/app/src/main/java/com/gaurav/avnc/ui/vnc/VncActivity.kt +++ b/app/src/main/java/com/gaurav/avnc/ui/vnc/VncActivity.kt @@ -277,9 +277,10 @@ class VncActivity : AppCompatActivity() { if (wasConnectedWhenStopped && (SystemClock.uptimeMillis() - onStartTime) in 0..2000) { Log.d(javaClass.simpleName, "Disconnected while in background, reconnecting ...") retryConnection(true) + return } - if (autoReconnecting || !viewModel.pref.server.autoReconnect) + if ((autoReconnecting || !viewModel.pref.server.autoReconnect) && !viewModel.profile.enableWol) return autoReconnecting = true diff --git a/app/src/main/java/com/gaurav/avnc/util/WakeOnLAN.kt b/app/src/main/java/com/gaurav/avnc/util/WakeOnLAN.kt new file mode 100644 index 0000000..d5f7aa8 --- /dev/null +++ b/app/src/main/java/com/gaurav/avnc/util/WakeOnLAN.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 Gaurav Ujjwal. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * See COPYING.txt for more details. + */ + +package com.gaurav.avnc.util + +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InterfaceAddress +import java.net.NetworkInterface +import java.nio.ByteBuffer + +private const val BROADCAST_PORT = 9 + +/** + * Parses given MAC address. + * Throws an exception if it is not a valid MAC address. + */ +@OptIn(ExperimentalStdlibApi::class) +fun parseMacAddress(macAddress: String): ByteArray { + return macAddress.hexToByteArray(HexFormat { bytes.byteSeparator = ":" }).also { check(it.size == 6) } +} + +/** + * Broadcasts Wake-on-LAN magic packet for [macAddress] to all available networks. + * Cannot be called from main thread. + */ +fun broadcastWoLPackets(macAddress: String) { + val macBytes = parseMacAddress(macAddress) + val addresses = getBroadcastAddresses() + + check(addresses.isNotEmpty()) { "No network interface is active" } + addresses.forEach { addr -> + val socket = DatagramSocket().apply { broadcast = true } + val packet = createMagicPacket(macBytes, addr) + + socket.use { it.send(packet) } + } +} + +/** + * Returns list of all broadcast addresses available on this system. + */ +private fun getBroadcastAddresses(): List { + return NetworkInterface.getNetworkInterfaces().toList() + .filter { it.isUp && !it.isLoopback } + .map { it.interfaceAddresses } + .fold(mutableListOf()) { list, addresses -> list.apply { addAll(addresses) } } + .mapNotNull { it.broadcast } +} + + +/** + * Creates WoL magic packet targeted at given broadcast address. + */ +private fun createMagicPacket(macBytes: ByteArray, broadcastAddress: InetAddress): DatagramPacket { + check(macBytes.size == 6) + val payload = ByteBuffer.allocate(6 + (16 * 6)).apply { + repeat(6) { put(255.toByte()) } + repeat(16) { put(macBytes) } + } + return DatagramPacket(payload.array(), payload.array().size, broadcastAddress, BROADCAST_PORT) +} diff --git a/app/src/main/java/com/gaurav/avnc/viewmodel/EditorViewModel.kt b/app/src/main/java/com/gaurav/avnc/viewmodel/EditorViewModel.kt index 5d72a7f..0a1c049 100644 --- a/app/src/main/java/com/gaurav/avnc/viewmodel/EditorViewModel.kt +++ b/app/src/main/java/com/gaurav/avnc/viewmodel/EditorViewModel.kt @@ -34,6 +34,7 @@ class EditorViewModel(app: Application, state: SavedStateHandle, initialProfile: val useRepeater = state.getLiveData("useRepeater", profile.useRepeater) val idOnRepeater = state.getLiveData("idOnRepeater", if (profile.useRepeater) profile.idOnRepeater.toString() else "") val useRawEncoding = state.getLiveData("useRawEncoding", profile.useRawEncoding) + val enableWol = state.getLiveData("enableWol", profile.enableWol) val useSshTunnel = state.getLiveData("useSshTunnel", profile.channelType == ServerProfile.CHANNEL_SSH_TUNNEL) val sshUsePassword = state.getLiveData("sshUsePassword", profile.sshAuthType == ServerProfile.SSH_AUTH_PASSWORD) val sshUsePrivateKey = state.getLiveData("sshUsePrivateKey", profile.sshAuthType == ServerProfile.SSH_AUTH_KEY) @@ -44,6 +45,7 @@ class EditorViewModel(app: Application, state: SavedStateHandle, initialProfile: profile.useRepeater = useRepeater.value ?: false profile.idOnRepeater = idOnRepeater.value?.toIntOrNull() ?: 0 profile.useRawEncoding = useRawEncoding.value ?: false + profile.enableWol = enableWol.value ?: false profile.channelType = if (useSshTunnel.value == true) ServerProfile.CHANNEL_SSH_TUNNEL else ServerProfile.CHANNEL_TCP profile.sshAuthType = if (sshUsePassword.value == true) ServerProfile.SSH_AUTH_PASSWORD else ServerProfile.SSH_AUTH_KEY return profile diff --git a/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt b/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt index b785757..02f562c 100644 --- a/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt +++ b/app/src/main/java/com/gaurav/avnc/viewmodel/VncViewModel.kt @@ -11,6 +11,7 @@ package com.gaurav.avnc.viewmodel import android.app.Application import android.graphics.RectF import android.util.Log +import android.widget.Toast import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.gaurav.avnc.model.LoginInfo @@ -19,6 +20,7 @@ import com.gaurav.avnc.ui.vnc.FrameScroller import com.gaurav.avnc.ui.vnc.FrameState import com.gaurav.avnc.ui.vnc.FrameView import com.gaurav.avnc.util.LiveRequest +import com.gaurav.avnc.util.broadcastWoLPackets import com.gaurav.avnc.util.getClipboardText import com.gaurav.avnc.util.setClipboardText import com.gaurav.avnc.viewmodel.service.HostKey @@ -216,6 +218,15 @@ class VncViewModel(val profile: ServerProfile, app: Application) : BaseViewModel if (profile.useRepeater) client.setupRepeater(profile.idOnRepeater) + + if (profile.enableWol) + runCatching { broadcastWoLPackets(profile.wolMAC) } + .onFailure { + launchMain { + Toast.makeText(app, "Wake-on-LAN: ${it.message}", Toast.LENGTH_LONG).show() + Log.w(javaClass.simpleName, "Cannot send WoL packet", it) + } + } } private fun connect() { diff --git a/app/src/main/res/drawable/ic_wake_on_lan.xml b/app/src/main/res/drawable/ic_wake_on_lan.xml new file mode 100644 index 0000000..c0de0cd --- /dev/null +++ b/app/src/main/res/drawable/ic_wake_on_lan.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_vnc.xml b/app/src/main/res/layout/activity_vnc.xml index 4b0932c..b536c9d 100644 --- a/app/src/main/res/layout/activity_vnc.xml +++ b/app/src/main/res/layout/activity_vnc.xml @@ -146,7 +146,7 @@ app:indicatorColor="?colorSecondary" app:indicatorInset="0dp" app:indicatorSize="@dimen/action_btn_size" - app:isVisible="@{viewModel.state == State.Disconnected && viewModel.pref.server.autoReconnect}" + app:isVisible="@{viewModel.state == State.Disconnected && (viewModel.pref.server.autoReconnect || viewModel.profile.enableWol)}" app:layout_constraintStart_toStartOf="@id/reconnect_btn" app:layout_constraintTop_toTopOf="@id/reconnect_btn" app:trackThickness="1dp" diff --git a/app/src/main/res/layout/fragment_profile_editor_advanced.xml b/app/src/main/res/layout/fragment_profile_editor_advanced.xml index 8e38a4b..905c909 100644 --- a/app/src/main/res/layout/fragment_profile_editor_advanced.xml +++ b/app/src/main/res/layout/fragment_profile_editor_advanced.xml @@ -153,6 +153,40 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/view_only" /> + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/wol_mac" /> Import View-only Auto-connect when app starts + Wake-on-LAN Hide remote pointer Send legacy key events Send delayed click events @@ -67,6 +68,7 @@ Server ID Username Password + MAC address SSH host SSH port Key password @@ -93,6 +95,7 @@ AVNC no longer saves private key passwords to improve security. Some servers have limited Unicode support. Legacy key events can help input non-English text. Delaying click events can help in some rare cases if an app is not responding to clicks. + Wake-on-LAN can be used to remotely power-on a computer.\n\nFirst, configure WoL on remote computer, then enable it in AVNC.\nOnce enabled, WoL magic packet will be automatically sent before connecting to this server. Assigning an action to this gesture will change Long press detection:\n\nPress-hold-release → Long press\nPress-hold-swipe → Long press and swipe This server has been deleted Server list is empty.\nClick \'+\' to add a server, or\nuse the top address bar to connect directly.