Skip to content

Commit

Permalink
Implement Wake-on-LAN support
Browse files Browse the repository at this point in the history
Re: #218
  • Loading branch information
gujjwal00 committed Jun 5, 2024
1 parent 2c2d0db commit 6a52227
Show file tree
Hide file tree
Showing 12 changed files with 398 additions and 4 deletions.
229 changes: 229 additions & 0 deletions app/roomSchema/com.gaurav.avnc.model.db.MainDb/6.json
Original file line number Diff line number Diff line change
@@ -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')"
]
}
}
12 changes: 12 additions & 0 deletions app/src/main/java/com/gaurav/avnc/model/ServerProfile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/gaurav/avnc/model/db/MainDb.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,7 +32,7 @@ abstract class MainDb : RoomDatabase() {
/**
* Current database version
*/
const val VERSION = 5
const val VERSION = 6

private var instance: MainDb? = null

Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/gaurav/avnc/ui/home/ProfileEditor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/gaurav/avnc/ui/vnc/VncActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions app/src/main/java/com/gaurav/avnc/util/WakeOnLAN.kt
Original file line number Diff line number Diff line change
@@ -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<InetAddress> {
return NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
.map { it.interfaceAddresses }
.fold(mutableListOf<InterfaceAddress>()) { 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)
}
Loading

0 comments on commit 6a52227

Please sign in to comment.