diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothClient.java b/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothClient.java index 0b8c7fda39..c0eca46b9c 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothClient.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothClient.java @@ -492,7 +492,50 @@ public void notifyDataObservers(String key, Object newValue) { observer.onReceiveValue(this, key, newValue); } } + /** + * Send a list of byte values to the connected Bluetooth device, followed by the specified delimiter byte. + * + * @param list the list of numeric values to write + * @param delimiter the delimiter byte as a String (e.g., "0" or "0x0A") + */ +@SimpleFunction(description = "Send a list of byte values to the connected Bluetooth device, followed by the specified delimiter byte.") +public void SendBytesWithDelimiter(YailList list, String delimiter) { + + // Send main bytes + SendBytes(list); + // Convert the delimiter from String → int + int value; + + try { + if (delimiter.startsWith("0x") || delimiter.startsWith("0X")) { + value = Integer.parseInt(delimiter.substring(2), 16); + } else { + value = Integer.parseInt(delimiter); + } + + if (value < 0 || value > 255) { + form.dispatchErrorOccurredEvent( + this, + "SendBytesWithDelimiter", + ErrorMessages.ERROR_BLUETOOTH_INVALID_DATA, + delimiter + ); + return; + } + + // Send exactly one byte + Send1ByteNumber(value); + + } catch (NumberFormatException e) { + form.dispatchErrorOccurredEvent( + this, + "SendBytesWithDelimiter", + ErrorMessages.ERROR_BLUETOOTH_INVALID_DATA, + delimiter + ); + } +} @Override public String getDataValue(String key) { String value = ""; diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothConnectionBase.java b/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothConnectionBase.java index 9099862c2a..b28412ed73 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothConnectionBase.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothConnectionBase.java @@ -35,6 +35,13 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + /** * An abstract base class for the BluetoothClient and BluetoothServer * component. @@ -60,6 +67,63 @@ public abstract class BluetoothConnectionBase extends AndroidNonvisibleComponent private InputStream inputStream; private OutputStream outputStream; + // ----------------------------- + // Timeout fields (NEW - minimal additions) + // ----------------------------- + // connection timeout is used by callers that will implement a connect wrapper (client/server). + // For this base class we expose the properties for consistency; client/server will use them. + private int connectionTimeoutMillis = 15000; // default 15s + private int readTimeoutMillis = 10000; // default 10s + private int writeTimeoutMillis = 5000; // default 5s + + @SimpleProperty(category = PropertyCategory.BEHAVIOR, + description = "Connection timeout in milliseconds. Default 15000.") + public int ConnectionTimeout() { + return connectionTimeoutMillis; + } + + @SimpleProperty + public void ConnectionTimeout(int millis) { + if (millis < 1000) { + connectionTimeoutMillis = 1000; + } else { + connectionTimeoutMillis = millis; + } + } + + @SimpleProperty(category = PropertyCategory.BEHAVIOR, + description = "Read timeout in milliseconds. Default 10000.") + public int ReadTimeout() { + return readTimeoutMillis; + } + + @SimpleProperty + public void ReadTimeout(int millis) { + if (millis < 100) { + readTimeoutMillis = 100; + } else { + readTimeoutMillis = millis; + } + } + + @SimpleProperty(category = PropertyCategory.BEHAVIOR, + description = "Write timeout in milliseconds. Default 5000.") + public int WriteTimeout() { + return writeTimeoutMillis; + } + + @SimpleProperty + public void WriteTimeout(int millis) { + if (millis < 100) { + writeTimeoutMillis = 100; + } else { + writeTimeoutMillis = millis; + } + } + // ----------------------------- + // End timeout fields + // ----------------------------- + /** * Creates a new BluetoothConnectionBase. */ @@ -512,17 +576,9 @@ protected void write(String functionName, byte b) { return; } - try { - outputStream.write(b); - outputStream.flush(); - } catch (IOException e) { - Log.e(logTag, "IO Exception during Writing" + e.getMessage()); - if (disconnectOnError) { - Disconnect(); - } - bluetoothError(functionName, - ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_WRITE, e.getMessage()); - } + // Wrap single byte write in timed write + byte[] arr = new byte[] { b }; + write(functionName, arr); } /** @@ -538,16 +594,49 @@ protected void write(String functionName, byte[] bytes) { return; } + // Implement write with timeout wrapper + ExecutorService exec = Executors.newSingleThreadExecutor(); + Future future = exec.submit(new Runnable() { + @Override + public void run() { + try { + outputStream.write(bytes); + outputStream.flush(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + try { - outputStream.write(bytes); - outputStream.flush(); - } catch (IOException e) { - Log.e(logTag, "IO Exception during Writing" + e.getMessage()); + future.get(writeTimeoutMillis, TimeUnit.MILLISECONDS); + } catch (TimeoutException te) { + future.cancel(true); + Log.e(logTag, "Write timed out."); if (disconnectOnError) { Disconnect(); } - bluetoothError(functionName, - ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_WRITE, e.getMessage()); + bluetoothError(functionName, ErrorMessages.ERROR_BLUETOOTH_TIMEOUT); + } catch (Exception e) { + // unwrap IOException wrapped in RuntimeException + Throwable cause = e.getCause(); + if (cause instanceof IOException) { + Log.e(logTag, "IO Exception during Writing" + cause.getMessage()); + if (disconnectOnError) { + Disconnect(); + } + bluetoothError(functionName, + ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_WRITE, cause.getMessage()); + } else { + Log.e(logTag, "Exception during Writing" + e.getMessage()); + if (disconnectOnError) { + Disconnect(); + } + bluetoothError(functionName, + ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_WRITE, e.getMessage()); + } + } finally { + exec.shutdownNow(); } } @@ -785,7 +874,7 @@ protected final byte[] read(String functionName, int numberOfBytes) { int totalBytesRead = 0; while (totalBytesRead < numberOfBytes) { try { - int numBytesRead = inputStream.read(bytes, totalBytesRead, bytes.length - totalBytesRead); + int numBytesRead = timedRead(bytes, totalBytesRead, bytes.length - totalBytesRead); if (numBytesRead == -1) { bluetoothError(functionName, ErrorMessages.ERROR_BLUETOOTH_END_OF_STREAM); @@ -800,6 +889,13 @@ protected final byte[] read(String functionName, int numberOfBytes) { bluetoothError(functionName, ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_READ, e.getMessage()); break; + } catch (TimeoutException te) { + Log.e(logTag, "Read timed out."); + if (disconnectOnError) { + Disconnect(); + } + bluetoothError(functionName, ErrorMessages.ERROR_BLUETOOTH_TIMEOUT); + break; } } buffer.write(bytes, 0, totalBytesRead); @@ -807,7 +903,7 @@ protected final byte[] read(String functionName, int numberOfBytes) { // Read one byte at a time until a delimiter byte is read. while (true) { try { - int value = inputStream.read(); + int value = timedReadSingle(); if (value == -1) { bluetoothError(functionName, ErrorMessages.ERROR_BLUETOOTH_END_OF_STREAM); @@ -825,6 +921,13 @@ protected final byte[] read(String functionName, int numberOfBytes) { bluetoothError(functionName, ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_READ, e.getMessage()); break; + } catch (TimeoutException te) { + Log.e(logTag, "Read timed out."); + if (disconnectOnError) { + Disconnect(); + } + bluetoothError(functionName, ErrorMessages.ERROR_BLUETOOTH_TIMEOUT); + break; } } } @@ -832,6 +935,81 @@ protected final byte[] read(String functionName, int numberOfBytes) { return buffer.toByteArray(); } + /** + * Helper that reads into buffer with read timeout applied. + * Returns number of bytes read or -1 on end of stream. + */ + private int timedRead(final byte[] b, final int off, final int len) + throws IOException, TimeoutException { + ExecutorService exec = Executors.newSingleThreadExecutor(); + Future future = exec.submit(new Callable() { + @Override + public Integer call() throws Exception { + try { + return inputStream.read(b, off, len); + } catch (IOException ioe) { + throw ioe; + } catch (Throwable t) { + throw new IOException("Read failed: " + t.getMessage()); + } + } + }); + + try { + Integer result = future.get(readTimeoutMillis, TimeUnit.MILLISECONDS); + return result == null ? -1 : result.intValue(); + } catch (TimeoutException te) { + future.cancel(true); + throw te; + } catch (Exception e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } else { + throw new IOException(e.getMessage()); + } + } finally { + exec.shutdownNow(); + } + } + + /** + * Helper that reads a single byte with read timeout. + * Returns byte value (0-255) or -1 on end of stream. + */ + private int timedReadSingle() throws IOException, TimeoutException { + ExecutorService exec = Executors.newSingleThreadExecutor(); + Future future = exec.submit(new Callable() { + @Override + public Integer call() throws Exception { + try { + return inputStream.read(); + } catch (IOException ioe) { + throw ioe; + } catch (Throwable t) { + throw new IOException("Read failed: " + t.getMessage()); + } + } + }); + + try { + Integer result = future.get(readTimeoutMillis, TimeUnit.MILLISECONDS); + return result == null ? -1 : result.intValue(); + } catch (TimeoutException te) { + future.cancel(true); + throw te; + } catch (Exception e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } else { + throw new IOException(e.getMessage()); + } + } finally { + exec.shutdownNow(); + } + } + // OnDestroyListener implementation @Override diff --git a/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothServer.java b/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothServer.java index 80c3f980b7..3bcd227381 100644 --- a/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothServer.java +++ b/appinventor/components/src/com/google/appinventor/components/runtime/BluetoothServer.java @@ -57,6 +57,24 @@ public final class BluetoothServer extends BluetoothConnectionBase { private final Handler androidUIHandler; private final AtomicReference arBluetoothServerSocket; + // Added Timeout Property + // ---------------------------------- + private int acceptTimeout = 15000; // 15 seconds default + + @SimpleProperty(description = "Timeout (in ms) for accepting a Bluetooth connection. Default = 15000.") + public int AcceptTimeout() { + return acceptTimeout; + } + + @SimpleProperty + public void AcceptTimeout(int timeout) { + if (timeout < 1000) { + acceptTimeout = 1000; // minimum 1s + } else { + acceptTimeout = timeout; + } + } + // ---------------------------------- /** * Creates a new BluetoothServer. @@ -118,7 +136,7 @@ public void HandlePermissionResponse(String permission, boolean granted) { try { BluetoothServerSocket socket; if (!secure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD_MR1) { - // listenUsingInsecureRfcommWithServiceRecord was introduced in level 10 + socket = adapter.listenUsingInsecureRfcommWithServiceRecord(name, uuid); } else { socket = adapter.listenUsingRfcommWithServiceRecord(name, uuid); @@ -137,24 +155,56 @@ public void run() { BluetoothServerSocket serverSocket = arBluetoothServerSocket.get(); if (serverSocket != null) { try { + // START OF NEW TIMEOUT WRAPPER (replaces blocking accept) + // ------------------------------------------------------- + java.util.concurrent.ExecutorService exec = + java.util.concurrent.Executors.newSingleThreadExecutor(); + + java.util.concurrent.Future future = + exec.submit(serverSocket::accept); + try { - acceptedSocket = serverSocket.accept(); - } catch (IOException e) { - androidUIHandler.post(new Runnable() { - public void run() { - form.dispatchErrorOccurredEvent(BluetoothServer.this, functionName, - ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_ACCEPT); - } - }); + acceptedSocket = future.get(acceptTimeout, + java.util.concurrent.TimeUnit.MILLISECONDS); + + } catch (java.util.concurrent.TimeoutException te) { + + future.cancel(true); + + androidUIHandler.post(() -> form.dispatchErrorOccurredEvent( + BluetoothServer.this, + functionName, + ErrorMessages.ERROR_BLUETOOTH_TIMEOUT + )); + + StopAccepting(); + exec.shutdownNow(); + return; + + } catch (Exception e) { + + androidUIHandler.post(() -> form.dispatchErrorOccurredEvent( + BluetoothServer.this, + functionName, + ErrorMessages.ERROR_BLUETOOTH_UNABLE_TO_ACCEPT + )); + + StopAccepting(); + exec.shutdownNow(); return; + + } finally { + exec.shutdownNow(); } + // ------------------------------------------------------- + // END OF NEW TIMEOUT WRAPPER } finally { StopAccepting(); } } if (acceptedSocket != null) { - // Call setConnection and signal the event on the main thread. + final BluetoothSocket bluetoothSocket = acceptedSocket; androidUIHandler.post(new Runnable() { public void run() {