diff --git a/js/ui/cordova/plugins/cordova-plugin-networking-bluetooth/src/android/NetworkingBluetooth.java b/js/ui/cordova/plugins/cordova-plugin-networking-bluetooth/src/android/NetworkingBluetooth.java new file mode 100644 index 0000000..471e563 --- /dev/null +++ b/js/ui/cordova/plugins/cordova-plugin-networking-bluetooth/src/android/NetworkingBluetooth.java @@ -0,0 +1,639 @@ +// Copyright 2016 Franco Bugnano +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cordova.plugin.networking.bluetooth; + +import org.apache.cordova.CallbackContext; +import org.apache.cordova.CordovaArgs; +import org.apache.cordova.CordovaInterface; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.CordovaWebView; +import org.apache.cordova.PluginResult; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.app.Activity; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothServerSocket; +import android.bluetooth.BluetoothSocket; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.Manifest; +import android.os.ParcelUuid; +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +public class NetworkingBluetooth extends CordovaPlugin { + public static final String TAG = "CordovaNetworkingBluetooth"; + public static final String SERVICE_NAME = "CordovaNetworkingBluetooth"; + public static final int REQUEST_ENABLE_BT = 1773; + public static final int REQUEST_DISCOVERABLE_BT = 1885; + public static final int START_DISCOVERY_REQ_CODE = 1997; + public static final int READ_BUFFER_SIZE = 4096; + + public class SocketSendData { + public CallbackContext mCallbackContext; + public BluetoothSocket mSocket; + public byte[] mData; + + public SocketSendData(CallbackContext callbackContext, BluetoothSocket socket, byte[] data) { + this.mCallbackContext = callbackContext; + this.mSocket = socket; + this.mData = data; + } + } + + public BluetoothAdapter mBluetoothAdapter = null; + public ConcurrentHashMap mContextForActivity = new ConcurrentHashMap(); + public ConcurrentHashMap mContextForPermission = new ConcurrentHashMap(); + public CallbackContext mContextForAdapterStateChanged = null; + public CallbackContext mContextForDeviceAdded = null; + public CallbackContext mContextForReceive = null; + public CallbackContext mContextForReceiveError = null; + public CallbackContext mContextForAccept = null; + public CallbackContext mContextForAcceptError = null; + public CallbackContext mContextForEnable = null; + public CallbackContext mContextForDisable = null; + public boolean mDeviceAddedRegistered = false; + public int mPreviousScanMode = BluetoothAdapter.SCAN_MODE_NONE; + public AtomicInteger mSocketId = new AtomicInteger(1); + public ConcurrentHashMap mClientSockets = new ConcurrentHashMap(); + public ConcurrentHashMap mServerSockets = new ConcurrentHashMap(); + public LinkedBlockingQueue mSendQueue = new LinkedBlockingQueue(); + + @Override + public void initialize(CordovaInterface cordova, CordovaWebView webView) { + super.initialize(cordova, webView); + + this.mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + + if (this.mBluetoothAdapter != null) { + this.mPreviousScanMode = this.mBluetoothAdapter.getScanMode(); + } + + cordova.getThreadPool().execute(new Runnable() { + public void run() { + writeLoop(); + } + }); + } + + @Override + public boolean execute(String action, CordovaArgs args, final CallbackContext callbackContext) throws JSONException { + IntentFilter filter; + + if (this.mBluetoothAdapter == null) { + callbackContext.error("Device does not support Bluetooth"); + return false; + } + + if (action.equals("registerAdapterStateChanged")) { + this.mContextForAdapterStateChanged = callbackContext; + + filter = new IntentFilter(); + filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED); + filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED); + filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); + filter.addAction(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED); + cordova.getActivity().registerReceiver(this.mReceiver, filter); + + return true; + } else if (action.equals("registerDeviceAdded")) { + this.mContextForDeviceAdded = callbackContext; + return true; + } else if (action.equals("registerReceive")) { + this.mContextForReceive = callbackContext; + return true; + } else if (action.equals("registerReceiveError")) { + this.mContextForReceiveError = callbackContext; + return true; + } else if (action.equals("registerAccept")) { + this.mContextForAccept = callbackContext; + return true; + } else if (action.equals("registerAcceptError")) { + this.mContextForAcceptError = callbackContext; + return true; + } else if (action.equals("getAdapterState")) { + this.getAdapterState(callbackContext, false); + return true; + } else if (action.equals("requestEnable")) { + if (!this.mBluetoothAdapter.isEnabled()) { + Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); + this.prepareActivity(action, args, callbackContext, enableBtIntent, REQUEST_ENABLE_BT); + } else { + callbackContext.success(); + } + return true; + } else if (action.equals("enable")) { + // If there already is another enable action pending, call the error callback in order + // to notify that the previous action has been cancelled + if (this.mContextForEnable != null) { + this.mContextForEnable.error(1); + this.mContextForEnable = null; + } + + if (!this.mBluetoothAdapter.isEnabled()) { + if (!this.mBluetoothAdapter.enable()) { + callbackContext.error(0); + } else { + // Save the context, in order to send the result once the action has been completed + this.mContextForEnable = callbackContext; + } + } else { + callbackContext.success(); + } + return true; + } else if (action.equals("disable")) { + // If there already is another disable action pending, call the error callback in order + // to notify that the previous action has been cancelled + if (this.mContextForDisable != null) { + this.mContextForDisable.error(1); + this.mContextForDisable = null; + } + + if (this.mBluetoothAdapter.isEnabled()) { + if (!this.mBluetoothAdapter.disable()) { + callbackContext.error(0); + } else { + // Save the context, in order to send the result once the action has been completed + this.mContextForDisable = callbackContext; + } + } else { + callbackContext.success(); + } + return true; + } else if (action.equals("getDevice")) { + String address = args.getString(0); + BluetoothDevice device = this.mBluetoothAdapter.getRemoteDevice(address); + callbackContext.success(this.getDeviceInfo(device)); + return true; + } else if (action.equals("getDevices")) { + Set devices = this.mBluetoothAdapter.getBondedDevices(); + JSONArray deviceInfos = new JSONArray(); + for (BluetoothDevice device : devices) { + deviceInfos.put(this.getDeviceInfo(device)); + } + callbackContext.success(deviceInfos); + return true; + } else if (action.equals("startDiscovery")) { + // Automatically cancel any previous discovery + if (this.mBluetoothAdapter.isDiscovering()) { + this.mBluetoothAdapter.cancelDiscovery(); + } + +// @blab TODO if (cordova.hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { + if (true) { + this.startDiscovery(callbackContext); + } else { + this.getPermission(callbackContext, START_DISCOVERY_REQ_CODE, Manifest.permission.ACCESS_COARSE_LOCATION); + } + return true; + } else if (action.equals("stopDiscovery")) { + if (this.mBluetoothAdapter.isDiscovering()) { + if (this.mBluetoothAdapter.cancelDiscovery()) { + callbackContext.success(); + } else { + callbackContext.error(0); + } + } else { + callbackContext.success(); + } + return true; + } else if (action.equals("requestDiscoverable")) { + Intent discoverableIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE); + discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300); + this.prepareActivity(action, args, callbackContext, discoverableIntent, REQUEST_DISCOVERABLE_BT); + return true; + } else if (action.equals("connect")) { + final String address = args.getString(0); + final String uuid = args.getString(1); + cordova.getThreadPool().execute(new Runnable() { + public void run() { + int socketId; + BluetoothSocket socket; + + try { + BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(address); + socket = device.createRfcommSocketToServiceRecord(UUID.fromString(uuid)); + + // Note: You should always ensure that the device is not performing + // device discovery when you call connect(). + // If discovery is in progress, then the connection attempt will be + // significantly slowed and is more likely to fail. + mBluetoothAdapter.cancelDiscovery(); + + socket.connect(); + + socketId = mSocketId.getAndIncrement(); + mClientSockets.put(socketId, socket); + callbackContext.success(socketId); + } catch (NullPointerException e) { + callbackContext.error(e.getMessage()); + return; + } catch (IllegalArgumentException e) { + callbackContext.error(e.getMessage()); + return; + } catch (IOException e) { + callbackContext.error(e.getMessage()); + return; + } + + // Now that the connection has been made, begin the read loop + readLoop(socketId, socket); + } + }); + return true; + } else if (action.equals("close")) { + int socketId = args.getInt(0); + BluetoothSocket socket = this.mClientSockets.remove(socketId); + if (socket != null) { + // The socketId refers to a client socket + try { + socket.close(); + callbackContext.success(); + } catch (IOException e) { + callbackContext.error(e.getMessage()); + } + } else { + BluetoothServerSocket serverSocket = this.mServerSockets.remove(socketId); + if (serverSocket != null) { + // The socketId refers to a server socket + try { + serverSocket.close(); + callbackContext.success(); + } catch (IOException e) { + callbackContext.error(e.getMessage()); + } + } else { + // Closing an already closed socket is not an error + callbackContext.success(); + } + } + return true; + } else if (action.equals("send")) { + int socketId = args.getInt(0); + byte[] data = args.getArrayBuffer(1); + BluetoothSocket socket = this.mClientSockets.get(socketId); + if (socket != null) { + try { + // The send operation occurs in a separate thread + this.mSendQueue.put(new SocketSendData(callbackContext, socket, data)); + } catch (InterruptedException e) { + callbackContext.error(e.getMessage()); + } + } else { + callbackContext.error("Invalid socketId"); + } + return true; + } else if (action.equals("listenUsingRfcomm")) { + final String uuid = args.getString(0); + cordova.getThreadPool().execute(new Runnable() { + public void run() { + int serverSocketId; + BluetoothServerSocket serverSocket; + + try { + serverSocket = mBluetoothAdapter.listenUsingRfcommWithServiceRecord(SERVICE_NAME, UUID.fromString(uuid)); + serverSocketId = mSocketId.getAndIncrement(); + mServerSockets.put(serverSocketId, serverSocket); + callbackContext.success(serverSocketId); + } catch (NullPointerException e) { + callbackContext.error(e.getMessage()); + return; + } catch (IllegalArgumentException e) { + callbackContext.error(e.getMessage()); + return; + } catch (IOException e) { + callbackContext.error(e.getMessage()); + return; + } + + // Now that the server socket has been made, begin the accept loop + acceptLoop(serverSocketId, serverSocket); + } + }); + return true; + } else { + callbackContext.error("Invalid action"); + return false; + } + } + + public void getAdapterState(CallbackContext callbackContext, boolean keepCallback) { + PluginResult pluginResult; + + try { + JSONObject adapterState = new JSONObject(); + adapterState.put("address", this.mBluetoothAdapter.getAddress()); + adapterState.put("name", this.mBluetoothAdapter.getName()); + adapterState.put("enabled", this.mBluetoothAdapter.isEnabled()); + adapterState.put("discovering", this.mBluetoothAdapter.isDiscovering()); + adapterState.put("discoverable", this.mBluetoothAdapter.getScanMode() == BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE); + + pluginResult = new PluginResult(PluginResult.Status.OK, adapterState); + pluginResult.setKeepCallback(keepCallback); + callbackContext.sendPluginResult(pluginResult); + } catch (JSONException e) { + pluginResult = new PluginResult(PluginResult.Status.ERROR, e.getMessage()); + pluginResult.setKeepCallback(keepCallback); + callbackContext.sendPluginResult(pluginResult); + } + } + + public JSONObject getDeviceInfo(BluetoothDevice device) throws JSONException { + JSONObject deviceInfo = new JSONObject(); + + deviceInfo.put("address", device.getAddress()); + deviceInfo.put("name", device.getName()); + deviceInfo.put("paired", device.getBondState() == BluetoothDevice.BOND_BONDED); + + JSONArray deviceUUIDs = new JSONArray(); + ParcelUuid[] uuids = device.getUuids(); + if (uuids != null) { + for (int i = 0; i < uuids.length; i++) { + deviceUUIDs.put(uuids[i].toString()); + } + } + deviceInfo.put("uuids", deviceUUIDs); + + return deviceInfo; + } + + public void prepareActivity(String action, CordovaArgs args, CallbackContext callbackContext, Intent intent, int requestCode) { + // If there already is another activity with this request code, call the error callback in order + // to notify that the activity has been cancelled + if (this.mContextForActivity.containsKey(requestCode)) { + callbackContext.error("Attempted to start the same activity twice"); + return; + } + + // Store the callbackContext, in order to send the result once the activity has been completed + this.mContextForActivity.put(requestCode, callbackContext); + + // Store the callbackContext, in order to send the result once the activity has been completed + cordova.startActivityForResult(this, intent, requestCode); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + CallbackContext callbackContext = this.mContextForActivity.remove(requestCode); + + if (callbackContext != null) { + if (resultCode == Activity.RESULT_CANCELED) { + callbackContext.error(0); + } else { + callbackContext.success(); + } + } else { + // TO DO -- This may be a bug on the JavaScript side, as we get here only if the + // activity has been started twice, before waiting the completion of the first one. + Log.e(TAG, "BUG: onActivityResult -- (callbackContext == null)"); + } + } + + public final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + PluginResult pluginResult; + + if (action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) { + int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1); + int previousState = intent.getIntExtra(BluetoothAdapter.EXTRA_PREVIOUS_STATE, -1); + + // If there was an enable request pending, send the result + if ((previousState == BluetoothAdapter.STATE_TURNING_ON) && (mContextForEnable != null)) { + if (state == BluetoothAdapter.STATE_ON) { + mContextForEnable.success(); + } else { + mContextForEnable.error(2); + } + mContextForEnable = null; + } + + // If there was a disable request pending, send the result + if ((previousState == BluetoothAdapter.STATE_TURNING_OFF) && (mContextForDisable != null)) { + if (state == BluetoothAdapter.STATE_OFF) { + mContextForDisable.success(); + } else { + mContextForDisable.error(2); + } + mContextForDisable = null; + } + + // Send the state changed event only if the state is not a transitioning one + if ((state == BluetoothAdapter.STATE_OFF) || (state == BluetoothAdapter.STATE_ON)) { + getAdapterState(mContextForAdapterStateChanged, true); + } + } else if (action.equals(BluetoothAdapter.ACTION_DISCOVERY_STARTED) || action.equals(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) { + getAdapterState(mContextForAdapterStateChanged, true); + } else if (action.equals(BluetoothDevice.ACTION_FOUND)) { + try { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + JSONObject deviceInfo = getDeviceInfo(device); + + pluginResult = new PluginResult(PluginResult.Status.OK, deviceInfo); + pluginResult.setKeepCallback(true); + mContextForDeviceAdded.sendPluginResult(pluginResult); + } catch (JSONException e) { + pluginResult = new PluginResult(PluginResult.Status.ERROR, e.getMessage()); + pluginResult.setKeepCallback(true); + mContextForDeviceAdded.sendPluginResult(pluginResult); + } + } else if (action.equals(BluetoothAdapter.ACTION_SCAN_MODE_CHANGED)) { + // BUG: The documented EXTRA_PREVIOUS_SCAN_MODE field of the intent is not implemented on Android. + // For details see: + // http://stackoverflow.com/questions/30553911/extra-previous-scan-mode-always-returns-an-error-for-android-bluetooth + // As a workaround, the previous scan mode is handled manually here + int scanMode = intent.getIntExtra(BluetoothAdapter.EXTRA_SCAN_MODE, -1); + + // Report only the transitions from/to SCAN_MODE_CONNECTABLE_DISCOVERABLE + if ((scanMode == BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE) || (mPreviousScanMode == BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE)) { + getAdapterState(mContextForAdapterStateChanged, true); + } + mPreviousScanMode = scanMode; + } + } + }; + + public void readLoop(int socketId, BluetoothSocket socket) { + byte[] readBuffer = new byte[READ_BUFFER_SIZE]; + byte[] data; + ArrayList multipartMessages; + PluginResult pluginResult; + + try { + InputStream stream = socket.getInputStream(); + int bytesRead; + + while (socket.isConnected()) { + bytesRead = stream.read(readBuffer); + if (bytesRead < 0) { + throw new IOException("Disconnected"); + } else if (bytesRead > 0) { + data = Arrays.copyOf(readBuffer, bytesRead); + multipartMessages = new ArrayList(); + multipartMessages.add(new PluginResult(PluginResult.Status.OK, socketId)); + multipartMessages.add(new PluginResult(PluginResult.Status.OK, data)); + pluginResult = new PluginResult(PluginResult.Status.OK, multipartMessages); + pluginResult.setKeepCallback(true); + this.mContextForReceive.sendPluginResult(pluginResult); + } + } + } catch (IOException e) { + try { + JSONObject info = new JSONObject(); + info.put("socketId", socketId); + info.put("errorMessage", e.getMessage()); + pluginResult = new PluginResult(PluginResult.Status.OK, info); + pluginResult.setKeepCallback(true); + this.mContextForReceiveError.sendPluginResult(pluginResult); + } catch (JSONException ex) {} + } + + try { + socket.close(); + } catch (IOException e) {} + + // The socket has been closed, remove its socketId + this.mClientSockets.remove(socketId); + } + + public void acceptLoop(int serverSocketId, BluetoothServerSocket serverSocket) { + int clientSocketId; + BluetoothSocket clientSocket; + ArrayList multipartMessages; + PluginResult pluginResult; + + try { + while (true) { + clientSocket = serverSocket.accept(); + if (clientSocket == null) { + throw new IOException("Disconnected"); + } + + clientSocketId = this.mSocketId.getAndIncrement(); + this.mClientSockets.put(clientSocketId, clientSocket); + + multipartMessages = new ArrayList(); + multipartMessages.add(new PluginResult(PluginResult.Status.OK, serverSocketId)); + multipartMessages.add(new PluginResult(PluginResult.Status.OK, clientSocketId)); + pluginResult = new PluginResult(PluginResult.Status.OK, multipartMessages); + pluginResult.setKeepCallback(true); + this.mContextForAccept.sendPluginResult(pluginResult); + + this.newReadLoopThread(clientSocketId, clientSocket); + } + } catch (IOException e) { + try { + JSONObject info = new JSONObject(); + info.put("socketId", serverSocketId); + info.put("errorMessage", e.getMessage()); + pluginResult = new PluginResult(PluginResult.Status.OK, info); + pluginResult.setKeepCallback(true); + this.mContextForAcceptError.sendPluginResult(pluginResult); + } catch (JSONException ex) {} + } + + try { + serverSocket.close(); + } catch (IOException e) {} + + // The socket has been closed, remove its socketId + this.mServerSockets.remove(serverSocketId); + } + + public void newReadLoopThread(final int socketId, final BluetoothSocket socket) { + cordova.getThreadPool().execute(new Runnable() { + public void run() { + readLoop(socketId, socket); + } + }); + } + + public void writeLoop() { + SocketSendData sendData; + + try { + while (true) { + sendData = this.mSendQueue.take(); + + try { + sendData.mSocket.getOutputStream().write(sendData.mData); + sendData.mCallbackContext.success(sendData.mData.length); + } catch (IOException e) { + sendData.mCallbackContext.error(e.getMessage()); + } + } + } catch (InterruptedException e) {} + } + + public void startDiscovery(CallbackContext callbackContext) { + if (!this.mDeviceAddedRegistered) { + IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND); + cordova.getActivity().registerReceiver(this.mReceiver, filter); + this.mDeviceAddedRegistered = true; + } + + if (this.mBluetoothAdapter.startDiscovery()) { + callbackContext.success(); + } else { + callbackContext.error(0); + } + } + + public void getPermission(CallbackContext callbackContext, int requestCode, String permission) { + // If there already is another permission request with this request code, call the error callback in order + // to notify that the request has been cancelled + if (this.mContextForPermission.containsKey(requestCode)) { + callbackContext.error("Attempted to request the same permission twice"); + return; + } + + // Store the callbackContext, in order to send the result once the activity has been completed + this.mContextForPermission.put(requestCode, callbackContext); + + // @blab TODO + // cordova.requestPermission(this, requestCode, permission); + } + + // @blab TODO @Override + public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) throws JSONException { + CallbackContext callbackContext = this.mContextForPermission.remove(requestCode); + + if (requestCode == START_DISCOVERY_REQ_CODE) { + if ((grantResults.length > 0) && (grantResults[0] == PackageManager.PERMISSION_GRANTED)) { + this.startDiscovery(callbackContext); + } else { + callbackContext.error(0); + } + } + } +} +