diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java index 21c66e58..9a760349 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/AutomationSnippet.java @@ -18,18 +18,58 @@ import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothManager; import android.content.Context; +import androidx.test.core.app.ApplicationProvider; + import com.google.android.mobly.snippet.Snippet; import com.google.android.mobly.snippet.rpc.Rpc; -import androidx.test.core.app.ApplicationProvider; - +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import java.security.InvalidParameterException; +import java.util.ArrayList; +import java.util.UUID; + +class Runner { + public UUID mId; + private final Mode mMode; + private final String mModeName; + private final String mScenario; + private final AppViewModel mModel; + + Runner(Mode mode, String modeName, String scenario, AppViewModel model) { + this.mId = UUID.randomUUID(); + this.mMode = mode; + this.mModeName = modeName; + this.mScenario = scenario; + this.mModel = model; + } + + public JSONObject toJson() throws JSONException { + JSONObject result = new JSONObject(); + result.put("id", mId.toString()); + result.put("mode", mModeName); + result.put("scenario", mScenario); + result.put("model", AutomationSnippet.modelToJson(mModel)); + + return result; + } + + public void stop() { + mModel.abort(); + } + + public void waitForCompletion() { + mMode.waitForCompletion(); + } +} + public class AutomationSnippet implements Snippet { private static final String TAG = "btbench.snippet"; private final BluetoothAdapter mBluetoothAdapter; private final Context mContext; + private final ArrayList mRunners = new ArrayList<>(); public AutomationSnippet() { mContext = ApplicationProvider.getApplicationContext(); @@ -40,30 +80,41 @@ public class AutomationSnippet implements Snippet { } } - private void runScenario(AppViewModel model, String mode, String scenario) { - Mode runner; + private Runner runScenario(AppViewModel model, String mode, String scenario) { + Mode runnable; switch (mode) { case "rfcomm-client": - runner = new RfcommClient(model, mBluetoothAdapter, (PacketIO packetIO) -> createIoClient(model, scenario, packetIO)); + runnable = new RfcommClient(model, mBluetoothAdapter, + (PacketIO packetIO) -> createIoClient(model, scenario, + packetIO)); break; case "rfcomm-server": - runner = new RfcommServer(model, mBluetoothAdapter, (PacketIO packetIO) -> createIoClient(model, scenario, packetIO)); + runnable = new RfcommServer(model, mBluetoothAdapter, + (PacketIO packetIO) -> createIoClient(model, scenario, + packetIO)); break; case "l2cap-client": - runner = new L2capClient(model, mBluetoothAdapter, mContext, (PacketIO packetIO) -> createIoClient(model, scenario, packetIO)); + runnable = new L2capClient(model, mBluetoothAdapter, mContext, + (PacketIO packetIO) -> createIoClient(model, scenario, + packetIO)); break; case "l2cap-server": - runner = new L2capServer(model, mBluetoothAdapter, (PacketIO packetIO) -> createIoClient(model, scenario, packetIO)); + runnable = new L2capServer(model, mBluetoothAdapter, + (PacketIO packetIO) -> createIoClient(model, scenario, + packetIO)); break; default: - return; + return null; } - runner.run(true); + runnable.run(); + Runner runner = new Runner(runnable, mode, scenario, model); + mRunners.add(runner); + return runner; } private IoClient createIoClient(AppViewModel model, String scenario, PacketIO packetIO) { @@ -85,35 +136,83 @@ public class AutomationSnippet implements Snippet { } } - private static JSONObject resultFromModel(AppViewModel model) throws JSONException { + public static JSONObject modelToJson(AppViewModel model) throws JSONException { JSONObject result = new JSONObject(); - JSONObject stats = new JSONObject(); - result.put("stats", stats); - JSONObject throughputStats = new JSONObject(); - stats.put("throughput", throughputStats); - throughputStats.put("average", model.getThroughput()); - JSONObject rttStats = new JSONObject(); - stats.put("rtt", rttStats); - rttStats.put("compound", model.getStats()); + result.put("status", model.getStatus()); + result.put("running", model.getRunning()); + result.put("l2cap_psm", model.getL2capPsm()); + if (model.getStatus().equals("OK")) { + JSONObject stats = new JSONObject(); + result.put("stats", stats); + stats.put("throughput", model.getThroughput()); + JSONObject rttStats = new JSONObject(); + stats.put("rtt", rttStats); + rttStats.put("compound", model.getStats()); + } else { + result.put("last_error", model.getLastError()); + } + return result; } + private Runner findRunner(String runnerId) { + for (Runner runner : mRunners) { + if (runner.mId.toString().equals(runnerId)) { + return runner; + } + } + + return null; + } + @Rpc(description = "Run a scenario in RFComm Client mode") - public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount, int packetSize, int packetInterval) throws JSONException { + public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount, + int packetSize, int packetInterval) throws JSONException { assert (mBluetoothAdapter != null); + + // We only support "send" and "ping" for this mode for now + if (!(scenario.equals("send") || scenario.equals("ping"))) { + throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode"); + } + AppViewModel model = new AppViewModel(); model.setPeerBluetoothAddress(peerBluetoothAddress); model.setSenderPacketCount(packetCount); model.setSenderPacketSize(packetSize); model.setSenderPacketInterval(packetInterval); - runScenario(model, "rfcomm-client", scenario); - return resultFromModel(model); + Runner runner = runScenario(model, "rfcomm-client", scenario); + assert runner != null; + return runner.toJson(); + } + + @Rpc(description = "Run a scenario in RFComm Server mode") + public JSONObject runRfcommServer(String scenario) throws JSONException { + assert (mBluetoothAdapter != null); + + // We only support "receive" and "pong" for this mode for now + if (!(scenario.equals("receive") || scenario.equals("pong"))) { + throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode"); + } + + AppViewModel model = new AppViewModel(); + + Runner runner = runScenario(model, "rfcomm-server", scenario); + assert runner != null; + return runner.toJson(); } @Rpc(description = "Run a scenario in L2CAP Client mode") - public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm, boolean use_2m_phy, int packetCount, int packetSize, int packetInterval) throws JSONException { + public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm, + boolean use_2m_phy, int packetCount, int packetSize, + int packetInterval) throws JSONException { assert (mBluetoothAdapter != null); + + // We only support "send" and "ping" for this mode for now + if (!(scenario.equals("send") || scenario.equals("ping"))) { + throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode"); + } + AppViewModel model = new AppViewModel(); model.setPeerBluetoothAddress(peerBluetoothAddress); model.setL2capPsm(psm); @@ -122,8 +221,66 @@ public class AutomationSnippet implements Snippet { model.setSenderPacketSize(packetSize); model.setSenderPacketInterval(packetInterval); - runScenario(model, "l2cap-client", scenario); - return resultFromModel(model); + Runner runner = runScenario(model, "l2cap-client", scenario); + assert runner != null; + return runner.toJson(); + } + + @Rpc(description = "Run a scenario in L2CAP Server mode") + public JSONObject runL2capServer(String scenario) throws JSONException { + assert (mBluetoothAdapter != null); + + // We only support "receive" and "pong" for this mode for now + if (!(scenario.equals("receive") || scenario.equals("pong"))) { + throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode"); + } + + AppViewModel model = new AppViewModel(); + + Runner runner = runScenario(model, "l2cap-server", scenario); + assert runner != null; + return runner.toJson(); + } + + @Rpc(description = "Stop a Runner") + public JSONObject stopRunner(String runnerId) throws JSONException { + Runner runner = findRunner(runnerId); + if (runner == null) { + return new JSONObject(); + } + runner.stop(); + return runner.toJson(); + } + + @Rpc(description = "Wait for a Runner to complete") + public JSONObject waitForRunnerCompletion(String runnerId) throws JSONException { + Runner runner = findRunner(runnerId); + if (runner == null) { + return new JSONObject(); + } + runner.waitForCompletion(); + return runner.toJson(); + } + + @Rpc(description = "Get a Runner by ID") + public JSONObject getRunner(String runnerId) throws JSONException { + Runner runner = findRunner(runnerId); + if (runner == null) { + return new JSONObject(); + } + return runner.toJson(); + } + + @Rpc(description = "Get all Runners") + public JSONObject getRunners() throws JSONException { + JSONObject result = new JSONObject(); + JSONArray runners = new JSONArray(); + result.put("runners", runners); + for (Runner runner: mRunners) { + runners.put(runner.toJson()); + } + + return result; } @Override diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt index 52db27b2..e7ff72f2 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt @@ -32,8 +32,10 @@ class L2capClient( private val context: Context, private val createIoClient: (packetIo: PacketIO) -> IoClient ) : Mode { + private var socketClient: SocketClient? = null + @SuppressLint("MissingPermission") - override fun run(blocking: Boolean) { + override fun run() { viewModel.running = true val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P") val address = viewModel.peerBluetoothAddress.take(17) @@ -97,7 +99,11 @@ class L2capClient( val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm) - val client = SocketClient(viewModel, socket, createIoClient) - client.run(blocking) + socketClient = SocketClient(viewModel, socket, createIoClient) + socketClient!!.run() + } + + override fun waitForCompletion() { + socketClient?.waitForCompletion() } } \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt index 23c54e23..0a1bb356 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt @@ -32,8 +32,10 @@ class L2capServer( private val bluetoothAdapter: BluetoothAdapter, private val createIoClient: (packetIo: PacketIO) -> IoClient ) : Mode { + private var socketServer: SocketServer? = null + @SuppressLint("MissingPermission") - override fun run(blocking: Boolean) { + override fun run() { // Advertise so that the peer can find us and connect. val callback = object : AdvertiseCallback() { override fun onStartFailure(errorCode: Int) { @@ -59,11 +61,14 @@ class L2capServer( viewModel.l2capPsm = serverSocket.psm Log.info("psm = $serverSocket.psm") - val server = SocketServer(viewModel, serverSocket, createIoClient) - server.run( + socketServer = SocketServer(viewModel, serverSocket, createIoClient) + socketServer!!.run( { advertiser.stopAdvertising(callback) }, - { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) }, - blocking + { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) } ) } + + override fun waitForCompletion() { + socketServer?.waitForCompletion() + } } \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt index 1fed7bc6..3676d492 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt @@ -210,7 +210,7 @@ class MainActivity : ComponentActivity() { L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient) else -> throw IllegalStateException() } - runner.run(false) + runner.run() } private fun runScan(startScan: Boolean) { @@ -443,6 +443,12 @@ fun MainView( Text( text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else "" ) + Text( + text = "Status: ${appViewModel.status}" + ) + Text( + text = "Last Error: ${appViewModel.lastError}" + ) Text( text = "Packets Sent: ${appViewModel.packetsSent}" ) diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Mode.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Mode.kt index 5d850ea1..70789ebe 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Mode.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Mode.kt @@ -15,5 +15,6 @@ package com.github.google.bumble.btbench interface Mode { - fun run(blocking: Boolean) + fun run() + fun waitForCompletion() } \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt index 66af8b4e..a58e3440 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt @@ -42,6 +42,8 @@ const val PONG_SCENARIO = "Pong" class AppViewModel : ViewModel() { private var preferences: SharedPreferences? = null + var status by mutableStateOf("") + var lastError by mutableStateOf("") var mode by mutableStateOf(RFCOMM_SERVER_MODE) var scenario by mutableStateOf(RECEIVE_SCENARIO) var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS) @@ -218,6 +220,18 @@ class AppViewModel : ViewModel() { } } + fun clear() { + status = "" + lastError = "" + mtu = 0 + rxPhy = 0 + txPhy = 0 + packetsSent = 0 + packetsReceived = 0 + throughput = 0 + stats = "" + } + fun abort() { aborter?.let { it() } } diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Pinger.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Pinger.kt index 666af384..6979a78d 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Pinger.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Pinger.kt @@ -34,9 +34,7 @@ class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO } override fun run() { - viewModel.packetsSent = 0 - viewModel.packetsReceived = 0 - viewModel.stats = "" + viewModel.clear() Log.info("startup delay: $DEFAULT_STARTUP_DELAY") Thread.sleep(DEFAULT_STARTUP_DELAY.toLong()); diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt index be38b374..50f0e47b 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Ponger.kt @@ -28,7 +28,9 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO packetIO.packetSink = this } - override fun run() {} + override fun run() { + viewModel.clear() + } override fun abort() {} diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt index 8eec3d53..71055c1f 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt @@ -29,7 +29,9 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet packetIO.packetSink = this } - override fun run() {} + override fun run() { + viewModel.clear() + } override fun abort() {} diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt index ef98ec45..da47fbbe 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt @@ -25,15 +25,21 @@ class RfcommClient( private val bluetoothAdapter: BluetoothAdapter, private val createIoClient: (packetIo: PacketIO) -> IoClient ) : Mode { + private var socketClient: SocketClient? = null + @SuppressLint("MissingPermission") - override fun run(blocking: Boolean) { + override fun run() { val address = viewModel.peerBluetoothAddress.take(17) val remoteDevice = bluetoothAdapter.getRemoteDevice(address) val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord( DEFAULT_RFCOMM_UUID ) - val client = SocketClient(viewModel, socket, createIoClient) - client.run(blocking) + socketClient = SocketClient(viewModel, socket, createIoClient) + socketClient!!.run() + } + + override fun waitForCompletion() { + socketClient?.waitForCompletion() } } diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt index c82237a6..349fbe1f 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt @@ -16,9 +16,7 @@ package com.github.google.bumble.btbench import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter -import java.io.IOException import java.util.logging.Logger -import kotlin.concurrent.thread private val Log = Logger.getLogger("btbench.rfcomm-server") @@ -27,13 +25,18 @@ class RfcommServer( private val bluetoothAdapter: BluetoothAdapter, private val createIoClient: (packetIo: PacketIO) -> IoClient ) : Mode { + private var socketServer: SocketServer? = null + @SuppressLint("MissingPermission") - override fun run(blocking: Boolean) { + override fun run() { val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord( "BumbleBench", DEFAULT_RFCOMM_UUID ) + socketServer = SocketServer(viewModel, serverSocket, createIoClient) + socketServer!!.run({}, {}) + } - val server = SocketServer(viewModel, serverSocket, createIoClient) - server.run({}, {}, blocking) + override fun waitForCompletion() { + socketServer?.waitForCompletion() } } \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt index 3ae05254..8ae4eac5 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt @@ -34,10 +34,7 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO } override fun run() { - viewModel.packetsSent = 0 - viewModel.packetsReceived = 0 - viewModel.throughput = 0 - viewModel.stats = "" + viewModel.clear() Log.info("startup delay: $DEFAULT_STARTUP_DELAY") Thread.sleep(DEFAULT_STARTUP_DELAY.toLong()); diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt index 4a41b2e0..43ca62ae 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt @@ -27,8 +27,10 @@ class SocketClient( private val socket: BluetoothSocket, private val createIoClient: (packetIo: PacketIO) -> IoClient ) { + private var clientThread: Thread? = null + @SuppressLint("MissingPermission") - fun run(blocking: Boolean = false) { + fun run() { viewModel.running = true val socketDataSink = SocketDataSink(socket) val streamIO = StreamedPacketIO(socketDataSink) @@ -41,7 +43,7 @@ class SocketClient( viewModel.running = false } - val clientThread = thread(name = "SocketClient") { + clientThread = thread(name = "SocketClient") { viewModel.aborter = { ioClient.abort() socket.close() @@ -51,6 +53,8 @@ class SocketClient( socket.connect() } catch (error: IOException) { Log.warning("connection failed") + viewModel.status = "ABORTED" + viewModel.lastError = "CONNECTION_FAILED" cleanup() return@thread } @@ -65,8 +69,11 @@ class SocketClient( try { ioClient.run() socket.close() + viewModel.status = "OK" } catch (error: IOException) { Log.info("run ended abruptly") + viewModel.status = "ABORTED" + viewModel.lastError = "IO_ERROR" } Log.info("waiting for source thread to finish") @@ -74,9 +81,9 @@ class SocketClient( cleanup() } + } - if (blocking) { - clientThread.join() - } + fun waitForCompletion() { + clientThread?.join() } } \ No newline at end of file diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt index bacc26c5..0954be45 100644 --- a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt +++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt @@ -26,7 +26,9 @@ class SocketServer( private val serverSocket: BluetoothServerSocket, private val createIoClient: (packetIo: PacketIO) -> IoClient ) { - fun run(onConnected: () -> Unit, onDisconnected: () -> Unit, blocking: Boolean = false) { + private var serverThread: Thread? = null + + fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) { var aborted = false viewModel.running = true @@ -35,7 +37,7 @@ class SocketServer( viewModel.running = false } - val serverThread = thread(name = "SocketServer") { + serverThread = thread(name = "SocketServer") { while (!aborted) { viewModel.aborter = { serverSocket.close() @@ -63,15 +65,22 @@ class SocketServer( val socketDataSink = SocketDataSink(socket) val streamIO = StreamedPacketIO(socketDataSink) val socketDataSource = SocketDataSource(socket, streamIO::onData) - val ioClient = createIoClient(streamIO) + + val ioThread = thread(name = "IoClient") { + val ioClient = createIoClient(streamIO) + ioClient.run() + } + socketDataSource.receive() socket.close() + ioThread.join() } cleanup() } - if (blocking) { - serverThread.join() - } + } + + fun waitForCompletion() { + serverThread?.join() } } \ No newline at end of file