udp socket binding istead of process binding to allow internet over network for maps etc

This commit is contained in:
Constantin Leue
2026-07-31 20:49:08 +02:00
parent 3b3451b8a2
commit 5e9ef43f2f
5 changed files with 365 additions and 167 deletions
@@ -18,5 +18,9 @@ class MainActivity : FlutterActivity() {
flutterEngine.dartExecutor.binaryMessenger,
MlrsNetworkPlugin.EVENT_CHANNEL,
).setStreamHandler(plugin)
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
MlrsNetworkPlugin.SOCKET_EVENT_CHANNEL,
).setStreamHandler(plugin.socketStreamHandler)
}
}
@@ -15,6 +15,12 @@ import android.os.PatternMatcher
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.net.BindException
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
import java.util.concurrent.Executors
/**
* Verbindungsaufbau zur mLRS-WLAN-Bruecke ausschliesslich aus der App heraus
@@ -25,12 +31,17 @@ import io.flutter.plugin.common.MethodChannel
* Ablauf: [requestNetwork] loest `ConnectivityManager.requestNetwork()` aus,
* das System zeigt dabei automatisch einen Bestaetigungsdialog fuer die
* passende SSID. `onAvailable`/`onUnavailable`/`onLost` werden ueber den
* EventChannel nach Dart gemeldet, das den restlichen Ablauf (Socket binden,
* lauschen, Fehleranzeige) uebernimmt.
* EventChannel nach Dart gemeldet.
*
* [bindProcessToNetwork]/[unbindProcessFromNetwork] setzen/loeschen die
* Prozessbindung fuer das kurze Fenster, in dem Dart den UDP-Socket erzeugt -
* siehe UdpTransport.connect() fuer die Gegenseite dieses Musters.
* Der UDP-Socket selbst wird ebenfalls hier (nativ) gehalten und per
* `Network.bindSocket()` **socket-genau** an das mLRS-Netz gebunden (Doku
* Abschnitt 4, Stufe 3) - bewusst NICHT ueber
* `ConnectivityManager.bindProcessToNetwork()`, das den gesamten Prozess
* (inkl. gleichzeitiger Kartenkacheln-/Wetter-HTTP-Requests) betrifft und in
* der Praxis auf Geraeten ohne STA-Concurrency zu genau diesen Requests
* gefuehrt hat, die "auf dem falschen Netz" haengen blieben. Socket-genaue
* Bindung ist Androids eigene Empfehlung fuer Apps, die gleichzeitig ein
* spezielles lokales Netz UND normales Internet brauchen.
*/
class MlrsNetworkPlugin(
private val context: Context,
@@ -39,23 +50,41 @@ class MlrsNetworkPlugin(
companion object {
const val METHOD_CHANNEL = "com.dmc.dmc_app/mlrs_network"
const val EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_network_events"
const val SOCKET_EVENT_CHANNEL = "com.dmc.dmc_app/mlrs_socket_events"
private const val RECEIVE_BUFFER_SIZE = 2048
}
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// NetworkCallback-Methoden laufen ohne expliziten Handler auf einem
// internen ConnectivityThread, nicht auf dem Main-Thread - Flutters
// EventChannel.EventSink.success() verlangt aber @UiThread (schlaegt
// sonst mit "Methods marked with @UiThread must be executed on the main
// thread" fehl). Deshalb Callback-Dispatch explizit auf den Main-Looper
// legen.
// NetworkCallback-Methoden und der Socket-Empfangs-Thread laufen nicht auf
// dem Main-Thread - Flutters EventChannel.EventSink.success()/
// MethodChannel.Result verlangen aber @UiThread (siehe Crash-Historie:
// "Methods marked with @UiThread must be executed on the main thread").
// Deshalb jeden Ruecksprung nach Dart explizit ueber den Main-Looper.
private val mainHandler = Handler(Looper.getMainLooper())
private val socketExecutor = Executors.newSingleThreadExecutor()
private var eventSink: EventChannel.EventSink? = null
private var socketEventSink: EventChannel.EventSink? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var currentNetwork: Network? = null
private var socket: DatagramSocket? = null
@Volatile
private var receiveLoopRunning = false
/** Separater StreamHandler fuer den Socket-Daten-Kanal (siehe MainActivity). */
val socketStreamHandler = object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
socketEventSink = sink
}
override fun onCancel(arguments: Any?) {
socketEventSink = null
}
}
override fun onListen(arguments: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
}
@@ -79,21 +108,27 @@ class MlrsNetworkPlugin(
releaseNetwork()
result.success(null)
}
"bindProcessToNetwork" -> {
val network = currentNetwork
if (network == null) {
result.success(false)
} else {
result.success(connectivityManager.bindProcessToNetwork(network))
}
}
"unbindProcessFromNetwork" -> {
connectivityManager.bindProcessToNetwork(null)
result.success(null)
}
"isStaConcurrencySupported" -> {
result.success(isStaConcurrencySupported())
}
"openSocket" -> {
val port = call.argument<Int>("port") ?: 0
openSocket(port, result)
}
"closeSocket" -> {
closeSocket()
result.success(null)
}
"sendPacket" -> {
val data = call.argument<ByteArray>("data")
val host = call.argument<String>("host")
val port = call.argument<Int>("port")
if (data == null || host == null || port == null) {
result.error("invalid_argument", "data/host/port erforderlich", null)
return
}
sendPacket(data, host, port, result)
}
else -> result.notImplemented()
}
}
@@ -161,4 +196,103 @@ class MlrsNetworkPlugin(
?: return null
return wifiManager.isStaConcurrencyForLocalOnlyConnectionsSupported
}
private fun openSocket(requestedPort: Int, result: MethodChannel.Result) {
val network = currentNetwork
if (network == null) {
result.error("no_network", "Kein mLRS-Netz verfuegbar, um den Socket zu binden.", null)
return
}
socketExecutor.execute {
try {
val newSocket = DatagramSocket(null)
// reuseAddress: false, damit ein tatsaechlich belegter Port
// hier erkennbar mit BindException scheitert (Doku 3/9: "ist
// 14550 belegt, ausweichen, aber protokollieren").
newSocket.reuseAddress = false
var fallbackFromPort: Int? = null
try {
newSocket.bind(InetSocketAddress(requestedPort))
} catch (e: BindException) {
newSocket.bind(InetSocketAddress(0))
fallbackFromPort = requestedPort
}
// Socket-genaue Bindung an das mLRS-Netz (siehe Klassendoku
// oben) - betrifft ausschliesslich diesen einen Socket, nicht
// den restlichen Prozess.
network.bindSocket(newSocket)
socket = newSocket
startReceiveLoop(newSocket)
val boundPort = newSocket.localPort
mainHandler.post {
result.success(
mapOf("boundPort" to boundPort, "fallbackFromPort" to fallbackFromPort),
)
}
} catch (e: Exception) {
mainHandler.post {
result.error("socket_error", e.message, null)
}
}
}
}
private fun startReceiveLoop(socket: DatagramSocket) {
receiveLoopRunning = true
Thread {
val buffer = ByteArray(RECEIVE_BUFFER_SIZE)
while (receiveLoopRunning) {
try {
val packet = DatagramPacket(buffer, buffer.size)
socket.receive(packet)
val data = packet.data.copyOfRange(packet.offset, packet.offset + packet.length)
val address = packet.address.hostAddress
val port = packet.port
mainHandler.post {
socketEventSink?.success(
mapOf("address" to address, "port" to port, "data" to data),
)
}
} catch (e: Exception) {
// Socket wurde geschlossen (closeSocket()) oder das Netz
// ist weg - Netzverlust wird bereits ueber den
// Network-Events-Kanal gemeldet, hier reicht es, die
// Empfangsschleife zu beenden statt in eine Busy-Loop zu
// laufen.
break
}
}
}.apply {
isDaemon = true
start()
}
}
private fun closeSocket() {
receiveLoopRunning = false
socket?.close()
socket = null
}
private fun sendPacket(data: ByteArray, host: String, port: Int, result: MethodChannel.Result) {
val currentSocket = socket
if (currentSocket == null) {
mainHandler.post {
result.error("no_socket", "Kein offener Socket zum Senden.", null)
}
return
}
socketExecutor.execute {
try {
val address = InetAddress.getByName(host)
currentSocket.send(DatagramPacket(data, data.size, address, port))
mainHandler.post { result.success(null) }
} catch (e: Exception) {
mainHandler.post { result.error("send_failed", e.message, null) }
}
}
}
}