/* Copyright (C) 2022-2024 Damien Gaignon, José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see . */
package nodomain.freeyourgadget.gadgetbridge.service.btbr;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.content.Context;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.os.Process;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Arrays;
import java.util.Locale;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public final class BtBRQueue {
private static final Logger LOG = LoggerFactory.getLogger(BtBRQueue.class);
public static final int HANDLER_SUBJECT_CONNECT = 0;
public static final int HANDLER_SUBJECT_PERFORM_TRANSACTION = 1;
private BluetoothAdapter mBtAdapter = null;
private BluetoothSocket mBtSocket = null;
private final GBDevice mGbDevice;
private final SocketCallback mCallback;
private final UUID mService;
private volatile boolean mDisposed;
private final Context mContext;
private final int mBufferSize;
private final Handler mWriteHandler;
private final HandlerThread mWriteHandlerThread = new HandlerThread("Write Thread", Process.THREAD_PRIORITY_BACKGROUND);
private Thread readThread = new Thread("Read Thread") {
@Override
public void run() {
byte[] buffer = new byte[mBufferSize];
int nRead;
LOG.debug("Read thread started, entering loop");
while (!mDisposed) {
try {
nRead = mBtSocket.getInputStream().read(buffer);
// safety measure
if (nRead == -1) {
throw new IOException("End of stream");
}
} catch (IOException ex) {
LOG.error("IO exception while reading message from socket, breaking out of read thread: ", ex);
break;
}
LOG.debug("Received {} bytes: {}", nRead, GB.hexdump(buffer, 0, nRead));
try {
mCallback.onSocketRead(Arrays.copyOf(buffer, nRead));
} catch (Throwable ex) {
LOG.error("Failed to process received bytes in onSocketRead callback: ", ex);
}
}
LOG.debug("Exited read thread loop, disconnecting");
GBApplication.deviceService(mGbDevice).disconnect();
}
};
public BtBRQueue(BluetoothAdapter btAdapter, GBDevice gbDevice, Context context, SocketCallback socketCallback, UUID supportedService, int bufferSize) {
mBtAdapter = btAdapter;
mGbDevice = gbDevice;
mContext = context;
mCallback = socketCallback;
mService = supportedService;
mBufferSize = bufferSize;
mWriteHandlerThread.start();
LOG.debug("Write handler thread is prepared, creating write handler");
mWriteHandler = new Handler(mWriteHandlerThread.getLooper()) {
@SuppressLint("MissingPermission")
@Override
public void handleMessage(@NonNull Message msg) {
switch (msg.what) {
case HANDLER_SUBJECT_CONNECT: {
try {
mBtSocket.connect();
LOG.info("Connected to RFCOMM socket for {}", mGbDevice.getName());
setDeviceConnectionState(GBDevice.State.CONNECTED);
// update thread names to show device names in logs
readThread.setName(String.format(Locale.ENGLISH,
"Read Thread for %s", mGbDevice.getName()));
mWriteHandlerThread.setName(String.format(Locale.ENGLISH,
"Write Thread for %s", mGbDevice.getName()));
// now that connect has been created, start the threads
readThread.start();
onConnectionEstablished();
} catch (IOException e) {
LOG.error("IO exception while establishing socket connection: ", e);
setDeviceConnectionState(GBDevice.State.NOT_CONNECTED);
}
return;
}
case HANDLER_SUBJECT_PERFORM_TRANSACTION: {
try {
if (!isConnected()) {
LOG.debug("Not connected, updating device state to WAITING_FOR_RECONNECT");
setDeviceConnectionState(GBDevice.State.WAITING_FOR_RECONNECT);
return;
}
if (!(msg.obj instanceof Transaction)) {
LOG.error("msg.obj is not an instance of Transaction");
return;
}
Transaction transaction = (Transaction) msg.obj;
for (BtBRAction action : transaction.getActions()) {
if (LOG.isDebugEnabled()) {
LOG.debug("About to run action: {}", action);
}
if (action.run(mBtSocket)) {
LOG.debug("Action ok: {}", action);
} else {
LOG.error("Action returned false, cancelling further actions in transaction: {}", action);
break;
}
}
} catch (Throwable ex) {
LOG.error("IO Write Thread died: " + ex.getMessage(), ex);
}
return;
}
}
LOG.warn("Unhandled write handler message {}", msg.what);
}
};
}
/**
* Connects to the given remote device. Note that this does not perform any device
* specific initialization. This should be done in the specific {@link DeviceSupport}
* class.
*
* @return true whether the connection attempt was successfully triggered and false if that failed or if there is already a connection
*/
@SuppressLint("MissingPermission")
public boolean connect() {
if (isConnected()) {
LOG.warn("Ignoring connect() because already connected.");
return false;
}
LOG.info("Attempting to connect to {} ({})", mGbDevice.getName(), mGbDevice.getAddress());
// stop discovery before connection is made
mBtAdapter.cancelDiscovery();
// revert to original state upon exception
GBDevice.State originalState = mGbDevice.getState();
setDeviceConnectionState(GBDevice.State.CONNECTING);
try {
BluetoothDevice btDevice = mBtAdapter.getRemoteDevice(mGbDevice.getAddress());
mBtSocket = btDevice.createRfcommSocketToServiceRecord(mService);
} catch (IOException e) {
LOG.error("Unable to connect to RFCOMM endpoint: ", e);
setDeviceConnectionState(originalState);
mBtSocket = null;
return false;
}
LOG.debug("Socket created, connecting in handler");
mWriteHandler.sendMessageAtFrontOfQueue(mWriteHandler.obtainMessage(HANDLER_SUBJECT_CONNECT));
return true;
}
protected void onConnectionEstablished() {
mCallback.onConnectionEstablished();
}
public void disconnect() {
if (mWriteHandlerThread.isAlive()) {
mWriteHandlerThread.quit();
}
if (mBtSocket != null && mBtSocket.isConnected()) {
try {
mBtSocket.close();
} catch (IOException e) {
LOG.error("IO exception while closing socket in disconnect(): ", e);
}
}
mBtSocket = null;
setDeviceConnectionState(GBDevice.State.NOT_CONNECTED);
}
/**
* Check whether a connection to the device exists and whether a socket connection has been
* initialized and connected
* @return true if the Bluetooth device is connected and the socket is ready, false otherwise
*/
private boolean isConnected() {
return mGbDevice.isConnected() &&
mBtSocket != null &&
mBtSocket.isConnected();
}
/**
* Add a finalized {@link Transaction} to the write handler's queue
*
* @param transaction The transaction to be run in the handler thread's looper
*/
public void add(Transaction transaction) {
LOG.debug("Adding transaction to looper message queue: {}", transaction);
if (!transaction.isEmpty()) {
mWriteHandler.obtainMessage(HANDLER_SUBJECT_PERFORM_TRANSACTION, transaction).sendToTarget();
}
}
private void setDeviceConnectionState(GBDevice.State newState) {
LOG.debug("New device connection state: " + newState);
mGbDevice.setState(newState);
mGbDevice.sendDeviceUpdateIntent(mContext, GBDevice.DeviceUpdateSubject.CONNECTION_STATE);
}
public void dispose() {
if (mDisposed) {
return;
}
mDisposed = true;
disconnect();
if (readThread != null && readThread.isAlive()) {
readThread.interrupt();
readThread = null;
}
}
}