From 15ea05898c2c7190f5a2c002d38330d630f86f84 Mon Sep 17 00:00:00 2001 From: erhannis Date: Wed, 21 Jun 2023 02:16:25 -0400 Subject: [PATCH 01/13] It runs again --- android/build.gradle | 4 ++-- android/gradle/wrapper/gradle-wrapper.properties | 2 +- android/src/main/AndroidManifest.xml | 14 ++++++++++++++ .../FlutterBluetoothSerialPlugin.java | 11 ++++++++++- example/android/app/build.gradle | 4 ++-- example/android/app/src/main/AndroidManifest.xml | 3 ++- example/android/build.gradle | 4 ++-- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 34 insertions(+), 10 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 082a0fea..0b58a5e1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' + classpath 'com.android.tools.build:gradle:7.0.0' } } rootProject.allprojects { @@ -18,7 +18,7 @@ rootProject.allprojects { } apply plugin: 'com.android.library' android { - compileSdkVersion 30 + compileSdkVersion 31 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c9d0852..dcf0f19c 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 3b5ff2f0..c63223e6 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -2,6 +2,20 @@ package="io.github.edufolly.flutterbluetoothserial"> + + + + + + + + + + + + + + diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java index 0b9686b4..2cdba338 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java @@ -450,9 +450,18 @@ private void ensurePermissions(EnsurePermissionsCallback callbacks) { != PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(activity, + Manifest.permission.BLUETOOTH_SCAN) + != PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(activity, + Manifest.permission.BLUETOOTH_ADVERTISE) + != PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(activity, + Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(activity, - new String[]{Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION}, + new String[]{Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.BLUETOOTH_CONNECT}, REQUEST_COARSE_LOCATION_PERMISSIONS); pendingPermissionsEnsureCallbacks = callbacks; diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 4b7eba10..e39d192e 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -15,7 +15,7 @@ apply plugin: 'com.android.application' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 30 + compileSdkVersion 31 lintOptions { disable 'InvalidPackage' } @@ -23,7 +23,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "io.github.edufolly.flutterbluetoothserialexample" minSdkVersion 19 - targetSdkVersion 30 + targetSdkVersion 31 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index bae56f3b..e46d2705 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -21,7 +21,8 @@ android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density" android:hardwareAccelerated="true" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:exported="true"> diff --git a/example/android/build.gradle b/example/android/build.gradle index c9e3db0a..9974c6fa 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' + classpath 'com.android.tools.build:gradle:7.0.0' } } @@ -24,6 +24,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index c732f9e0..7026afc0 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip From 013d7c665bfd5e22adb11003d57faf921fdd37de Mon Sep 17 00:00:00 2001 From: erhannis Date: Wed, 21 Jun 2023 18:16:42 -0400 Subject: [PATCH 02/13] BROKEN Working on integrating BLE code from SimpleBluetoothLeTerminal --- .../BluetoothConnection.java | 184 +---- .../BluetoothConnectionClassic.java | 182 +++++ .../FlutterBluetoothSerialPlugin.java | 6 +- .../le/BluetoothUtil.java | 130 ++++ .../flutterbluetoothserial/le/Constants.java | 14 + .../le/DevicesFragment.java | 297 ++++++++ .../le/MainActivity.java | 33 + .../le/SerialListener.java | 11 + .../le/SerialService.java | 279 ++++++++ .../le/SerialSocket.java | 643 ++++++++++++++++++ .../le/TerminalFragment.java | 293 ++++++++ .../flutterbluetoothserial/le/TextUtil.java | 155 +++++ 12 files changed, 2049 insertions(+), 178 deletions(-) create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialSocket.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TextUtil.java diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java index c3b20ee7..85558ca3 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java @@ -1,186 +1,20 @@ package io.github.edufolly.flutterbluetoothserial; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.util.UUID; -import java.util.Arrays; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothSocket; - -/// Universal Bluetooth serial connection class (for Java) -public abstract class BluetoothConnection -{ - protected static final UUID DEFAULT_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); - - protected BluetoothAdapter bluetoothAdapter; - - protected ConnectionThread connectionThread = null; - - public boolean isConnected() { - return connectionThread != null && connectionThread.requestedClosing != true; - } - - - - public BluetoothConnection(BluetoothAdapter bluetoothAdapter) { - this.bluetoothAdapter = bluetoothAdapter; - } - - - - // @TODO . `connect` could be done perfored on the other thread - // @TODO . `connect` parameter: timeout - // @TODO . `connect` other methods than `createRfcommSocketToServiceRecord`, including hidden one raw `createRfcommSocket` (on channel). - // @TODO ? how about turning it into factoried? +public interface BluetoothConnection { + public boolean isConnected(); /// Connects to given device by hardware address - public void connect(String address, UUID uuid) throws IOException { - if (isConnected()) { - throw new IOException("already connected"); - } - - BluetoothDevice device = bluetoothAdapter.getRemoteDevice(address); - if (device == null) { - throw new IOException("device not found"); - } - - BluetoothSocket socket = device.createRfcommSocketToServiceRecord(uuid); // @TODO . introduce ConnectionMethod - if (socket == null) { - throw new IOException("socket connection not established"); - } - - // Cancel discovery, even though we didn't start it - bluetoothAdapter.cancelDiscovery(); - - socket.connect(); - - connectionThread = new ConnectionThread(socket); - connectionThread.start(); - } + public void connect(String address, UUID uuid) throws IOException; /// Connects to given device by hardware address (default UUID used) - public void connect(String address) throws IOException { - connect(address, DEFAULT_UUID); - } - + public void connect(String address) throws IOException; /// Disconnects current session (ignore if not connected) - public void disconnect() { - if (isConnected()) { - connectionThread.cancel(); - connectionThread = null; - } - } - - /// Writes to connected remote device - public void write(byte[] data) throws IOException { - if (!isConnected()) { - throw new IOException("not connected"); - } - - connectionThread.write(data); - } - + public void disconnect(); + /// Writes to connected remote device + public void write(byte[] data) throws IOException; /// Callback for reading data. - protected abstract void onRead(byte[] data); - + public void onRead(byte[] data); /// Callback for disconnection. - protected abstract void onDisconnected(boolean byRemote); - - /// Thread to handle connection I/O - private class ConnectionThread extends Thread { - private final BluetoothSocket socket; - private final InputStream input; - private final OutputStream output; - private boolean requestedClosing = false; - - ConnectionThread(BluetoothSocket socket) { - this.socket = socket; - InputStream tmpIn = null; - OutputStream tmpOut = null; - - try { - tmpIn = socket.getInputStream(); - tmpOut = socket.getOutputStream(); - } catch (IOException e) { - e.printStackTrace(); - } - - this.input = tmpIn; - this.output = tmpOut; - } - - /// Thread main code - public void run() { - byte[] buffer = new byte[1024]; - int bytes; - - while (!requestedClosing) { - try { - bytes = input.read(buffer); - - onRead(Arrays.copyOf(buffer, bytes)); - } catch (IOException e) { - // `input.read` throws when closed by remote device - break; - } - } - - // Make sure output stream is closed - if (output != null) { - try { - output.close(); - } - catch (Exception e) {} - } - - // Make sure input stream is closed - if (input != null) { - try { - input.close(); - } - catch (Exception e) {} - } - - // Callback on disconnected, with information which side is closing - onDisconnected(!requestedClosing); - - // Just prevent unnecessary `cancel`ing - requestedClosing = true; - } - - /// Writes to output stream - public void write(byte[] bytes) { - try { - output.write(bytes); - } catch (IOException e) { - e.printStackTrace(); - } - } - - /// Stops the thread, disconnects - public void cancel() { - if (requestedClosing) { - return; - } - requestedClosing = true; - - // Flush output buffers befoce closing - try { - output.flush(); - } - catch (Exception e) {} - - // Close the connection socket - if (socket != null) { - try { - // Might be useful (see https://stackoverflow.com/a/22769260/4880243) - Thread.sleep(111); - - socket.close(); - } - catch (Exception e) {} - } - } + public void onDisconnected(boolean byRemote); } -} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java new file mode 100644 index 00000000..625307f7 --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java @@ -0,0 +1,182 @@ +package io.github.edufolly.flutterbluetoothserial; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.UUID; +import java.util.Arrays; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothSocket; + +/// Universal Bluetooth serial connection class (for Java) +public abstract class BluetoothConnectionClassic implements BluetoothConnection +{ + protected static final UUID DEFAULT_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); + + protected BluetoothAdapter bluetoothAdapter; + + protected ConnectionThread connectionThread = null; + + public boolean isConnected() { + return connectionThread != null && connectionThread.requestedClosing != true; + } + + + + public BluetoothConnectionClassic(BluetoothAdapter bluetoothAdapter) { + this.bluetoothAdapter = bluetoothAdapter; + } + + + + // @TODO . `connect` could be done perfored on the other thread + // @TODO . `connect` parameter: timeout + // @TODO . `connect` other methods than `createRfcommSocketToServiceRecord`, including hidden one raw `createRfcommSocket` (on channel). + // @TODO ? how about turning it into factoried? + public void connect(String address, UUID uuid) throws IOException { + if (isConnected()) { + throw new IOException("already connected"); + } + + BluetoothDevice device = bluetoothAdapter.getRemoteDevice(address); + if (device == null) { + throw new IOException("device not found"); + } + + BluetoothSocket socket = device.createRfcommSocketToServiceRecord(uuid); // @TODO . introduce ConnectionMethod + if (socket == null) { + throw new IOException("socket connection not established"); + } + + // Cancel discovery, even though we didn't start it + bluetoothAdapter.cancelDiscovery(); + + socket.connect(); + + connectionThread = new ConnectionThread(socket); + connectionThread.start(); + } + + public void connect(String address) throws IOException { + connect(address, DEFAULT_UUID); + } + + public void disconnect() { + if (isConnected()) { + connectionThread.cancel(); + connectionThread = null; + } + } + + public void write(byte[] data) throws IOException { + if (!isConnected()) { + throw new IOException("not connected"); + } + + connectionThread.write(data); + } + + public abstract void onRead(byte[] data); + + /// Callback for disconnection. + public abstract void onDisconnected(boolean byRemote); + + /// Thread to handle connection I/O + private class ConnectionThread extends Thread { + private final BluetoothSocket socket; + private final InputStream input; + private final OutputStream output; + private boolean requestedClosing = false; + + ConnectionThread(BluetoothSocket socket) { + this.socket = socket; + InputStream tmpIn = null; + OutputStream tmpOut = null; + + try { + tmpIn = socket.getInputStream(); + tmpOut = socket.getOutputStream(); + } catch (IOException e) { + e.printStackTrace(); + } + + this.input = tmpIn; + this.output = tmpOut; + } + + /// Thread main code + public void run() { + byte[] buffer = new byte[1024]; + int bytes; + + while (!requestedClosing) { + try { + bytes = input.read(buffer); + + onRead(Arrays.copyOf(buffer, bytes)); + } catch (IOException e) { + // `input.read` throws when closed by remote device + break; + } + } + + // Make sure output stream is closed + if (output != null) { + try { + output.close(); + } + catch (Exception e) {} + } + + // Make sure input stream is closed + if (input != null) { + try { + input.close(); + } + catch (Exception e) {} + } + + // Callback on disconnected, with information which side is closing + onDisconnected(!requestedClosing); + + // Just prevent unnecessary `cancel`ing + requestedClosing = true; + } + + /// Writes to output stream + public void write(byte[] bytes) { + try { + output.write(bytes); + } catch (IOException e) { + e.printStackTrace(); + } + } + + /// Stops the thread, disconnects + public void cancel() { + if (requestedClosing) { + return; + } + requestedClosing = true; + + // Flush output buffers befoce closing + try { + output.flush(); + } + catch (Exception e) {} + + // Close the connection socket + if (socket != null) { + try { + // Might be useful (see https://stackoverflow.com/a/22769260/4880243) + Thread.sleep(111); + + socket.close(); + } + catch (Exception e) {} + } + } + } +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java index 2cdba338..46c403b4 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java @@ -492,7 +492,7 @@ static private boolean checkIsDeviceConnected(BluetoothDevice device) { /// Helper wrapper class for `BluetoothConnection` - private class BluetoothConnectionWrapper extends BluetoothConnection { + private class BluetoothConnectionWrapper extends BluetoothConnectionClassic { private final int id; protected EventSink readSink; @@ -532,7 +532,7 @@ public void onCancel(Object o) { } @Override - protected void onRead(byte[] buffer) { + public void onRead(byte[] buffer) { activity.runOnUiThread(() -> { if (readSink != null) { readSink.success(buffer); @@ -541,7 +541,7 @@ protected void onRead(byte[] buffer) { } @Override - protected void onDisconnected(boolean byRemote) { + public void onDisconnected(boolean byRemote) { activity.runOnUiThread(() -> { if (byRemote) { Log.d(TAG, "onDisconnected by remote (id: " + id + ")"); diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java new file mode 100644 index 00000000..08af1bcf --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java @@ -0,0 +1,130 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.bluetooth.BluetoothDevice; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.fragment.app.Fragment; + +import java.util.Map; + +public class BluetoothUtil { + + interface PermissionGrantedCallback { + void call(); + } + + /* + * more efficient caching of name than BluetoothDevice which always does RPC + */ + static class Device implements Comparable { + BluetoothDevice device; + String name; + + @SuppressLint("MissingPermission") + public Device(BluetoothDevice device) { + this.device = device; + this.name = device.getName(); + } + + public BluetoothDevice getDevice() { return device; } + public String getName() { return name; } + + @Override + public boolean equals(Object o) { + if (o instanceof Device) + return device.equals(((Device) o).device); + return false; + } + + /** + * sort by name, then address. sort named devices first + */ + @Override + public int compareTo(Device other) { + boolean thisValid = this.name!=null && !this.name.isEmpty(); + boolean otherValid = other.name!=null && !other.name.isEmpty(); + if(thisValid && otherValid) { + int ret = this.name.compareTo(other.name); + if (ret != 0) return ret; + return this.device.getAddress().compareTo(other.device.getAddress()); + } + if(thisValid) return -1; + if(otherValid) return +1; + return this.device.getAddress().compareTo(other.device.getAddress()); + } + + } + + + /** + * Android 12 permission handling + */ + private static void showRationaleDialog(Fragment fragment, DialogInterface.OnClickListener listener) { + final AlertDialog.Builder builder = new AlertDialog.Builder(fragment.getActivity()); + builder.setTitle(fragment.getString(R.string.bluetooth_permission_title)); + builder.setMessage(fragment.getString(R.string.bluetooth_permission_grant)); + builder.setNegativeButton("Cancel", null); + builder.setPositiveButton("Continue", listener); + builder.show(); + } + + private static void showSettingsDialog(Fragment fragment) { + String s = fragment.getResources().getString(fragment.getResources().getIdentifier("@android:string/permgrouplab_nearby_devices", null, null)); + final AlertDialog.Builder builder = new AlertDialog.Builder(fragment.getActivity()); + builder.setTitle(fragment.getString(R.string.bluetooth_permission_title)); + builder.setMessage(String.format(fragment.getString(R.string.bluetooth_permission_denied), s)); + builder.setNegativeButton("Cancel", null); + builder.setPositiveButton("Settings", (dialog, which) -> + fragment.startActivity(new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + BuildConfig.APPLICATION_ID)))); + builder.show(); + } + + /** + * CONNECT + SCAN are granted together in same permission group, so actually no need to check/request both, but one never knows + */ + static boolean hasPermissions(Fragment fragment, ActivityResultLauncher requestPermissionLauncher) { + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S) + return true; + boolean missingPermissions = fragment.getActivity().checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED + | fragment.getActivity().checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED; + boolean showRationale = fragment.shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_CONNECT) + | fragment.shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_SCAN); + String[] permissions = new String[]{Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN}; + if(missingPermissions) { + if (showRationale) { + showRationaleDialog(fragment, (dialog, which) -> + requestPermissionLauncher.launch(permissions)); + } else { + requestPermissionLauncher.launch(permissions); + } + return false; + } else { + return true; + } + } + + static void onPermissionsResult(Fragment fragment, Map grants, PermissionGrantedCallback cb) { + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S) + return; + boolean showRationale = fragment.shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_CONNECT) + | fragment.shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_SCAN); + boolean granted = grants.values().stream().reduce(true, (a, b) -> a && b); + if (granted) { + cb.call(); + } else if (showRationale) { + showRationaleDialog(fragment, (dialog, which) -> cb.call()); + } else { + showSettingsDialog(fragment); + } + } + +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java new file mode 100644 index 00000000..6156cba3 --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java @@ -0,0 +1,14 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +class Constants { + + // values have to be globally unique + static final String INTENT_ACTION_DISCONNECT = BuildConfig.APPLICATION_ID + ".Disconnect"; + static final String NOTIFICATION_CHANNEL = BuildConfig.APPLICATION_ID + ".Channel"; + static final String INTENT_CLASS_MAIN_ACTIVITY = BuildConfig.APPLICATION_ID + ".MainActivity"; + + // values have to be unique within each app + static final int NOTIFY_MANAGER_START_FOREGROUND_SERVICE = 1001; + + private Constants() {} +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java new file mode 100644 index 00000000..53aa28d8 --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java @@ -0,0 +1,297 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.location.LocationManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.ListFragment; + +import java.util.ArrayList; +import java.util.Collections; + +/** + * show list of BLE devices + */ +public class DevicesFragment extends ListFragment { + + private enum ScanState { NONE, LE_SCAN, DISCOVERY, DISCOVERY_FINISHED } + private ScanState scanState = ScanState.NONE; + private static final long LE_SCAN_PERIOD = 10000; // similar to bluetoothAdapter.startDiscovery + private final Handler leScanStopHandler = new Handler(); + private final BluetoothAdapter.LeScanCallback leScanCallback; + private final Runnable leScanStopCallback; + private final BroadcastReceiver discoveryBroadcastReceiver; + private final IntentFilter discoveryIntentFilter; + + private Menu menu; + private BluetoothAdapter bluetoothAdapter; + private final ArrayList listItems = new ArrayList<>(); + private ArrayAdapter listAdapter; + ActivityResultLauncher requestBluetoothPermissionLauncherForStartScan; + ActivityResultLauncher requestLocationPermissionLauncherForStartScan; + + public DevicesFragment() { + leScanCallback = (device, rssi, scanRecord) -> { + if(device != null && getActivity() != null) { + getActivity().runOnUiThread(() -> { updateScan(device); }); + } + }; + discoveryBroadcastReceiver = new BroadcastReceiver() { + @SuppressLint("MissingPermission") + @Override + public void onReceive(Context context, Intent intent) { + if(BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if(device.getType() != BluetoothDevice.DEVICE_TYPE_CLASSIC && getActivity() != null) { + getActivity().runOnUiThread(() -> updateScan(device)); + } + } + if(BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(intent.getAction())) { + scanState = ScanState.DISCOVERY_FINISHED; // don't cancel again + stopScan(); + } + } + }; + discoveryIntentFilter = new IntentFilter(); + discoveryIntentFilter.addAction(BluetoothDevice.ACTION_FOUND); + discoveryIntentFilter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); + leScanStopCallback = this::stopScan; // w/o explicit Runnable, a new lambda would be created on each postDelayed, which would not be found again by removeCallbacks + requestBluetoothPermissionLauncherForStartScan = registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + granted -> BluetoothUtil.onPermissionsResult(this, granted, this::startScan)); + requestLocationPermissionLauncherForStartScan = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + granted -> { + if (granted) { + new Handler(Looper.getMainLooper()).postDelayed(this::startScan, 1); // run after onResume to avoid wrong empty-text + } else { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getText(R.string.location_permission_title)); + builder.setMessage(getText(R.string.location_permission_denied)); + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } + }); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + if(getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) + bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + listAdapter = new ArrayAdapter(getActivity(), 0, listItems) { + @NonNull + @Override + public View getView(int position, View view, @NonNull ViewGroup parent) { + BluetoothUtil.Device device = listItems.get(position); + if (view == null) + view = getActivity().getLayoutInflater().inflate(R.layout.device_list_item, parent, false); + TextView text1 = view.findViewById(R.id.text1); + TextView text2 = view.findViewById(R.id.text2); + String deviceName = device.getName(); + if(deviceName == null || deviceName.isEmpty()) + deviceName = ""; + text1.setText(deviceName); + text2.setText(device.getDevice().getAddress()); + return view; + } + }; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + setListAdapter(null); + View header = getActivity().getLayoutInflater().inflate(R.layout.device_list_header, null, false); + getListView().addHeaderView(header, null, false); + setEmptyText("initializing..."); + ((TextView) getListView().getEmptyView()).setTextSize(18); + setListAdapter(listAdapter); + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_devices, menu); + this.menu = menu; + if (bluetoothAdapter == null) { + menu.findItem(R.id.bt_settings).setEnabled(false); + menu.findItem(R.id.ble_scan).setEnabled(false); + } else if(!bluetoothAdapter.isEnabled()) { + menu.findItem(R.id.ble_scan).setEnabled(false); + } + } + + @Override + public void onResume() { + super.onResume(); + getActivity().registerReceiver(discoveryBroadcastReceiver, discoveryIntentFilter); + if(bluetoothAdapter == null) { + setEmptyText(""); + } else if(!bluetoothAdapter.isEnabled()) { + setEmptyText(""); + if (menu != null) { + listItems.clear(); + listAdapter.notifyDataSetChanged(); + menu.findItem(R.id.ble_scan).setEnabled(false); + } + } else { + setEmptyText(""); + if (menu != null) + menu.findItem(R.id.ble_scan).setEnabled(true); + } + } + + @Override + public void onPause() { + super.onPause(); + stopScan(); + getActivity().unregisterReceiver(discoveryBroadcastReceiver); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + menu = null; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + if (id == R.id.ble_scan) { + startScan(); + return true; + } else if (id == R.id.ble_scan_stop) { + stopScan(); + return true; + } else if (id == R.id.bt_settings) { + Intent intent = new Intent(); + intent.setAction(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS); + startActivity(intent); + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + private void startScan() { + if(scanState != ScanState.NONE) + return; + ScanState nextScanState = ScanState.LE_SCAN; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if(!BluetoothUtil.hasPermissions(this, requestBluetoothPermissionLauncherForStartScan)) + return; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (getActivity().checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + scanState = ScanState.NONE; + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(R.string.location_permission_title); + builder.setMessage(R.string.location_permission_grant); + builder.setPositiveButton(android.R.string.ok, + (dialog, which) -> requestLocationPermissionLauncherForStartScan.launch(Manifest.permission.ACCESS_FINE_LOCATION)); + builder.show(); + return; + } + LocationManager locationManager = (LocationManager) getActivity().getSystemService(Context.LOCATION_SERVICE); + boolean locationEnabled = false; + try { + locationEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); + } catch(Exception ignored) {} + try { + locationEnabled |= locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); + } catch(Exception ignored) {} + if(!locationEnabled) + scanState = ScanState.DISCOVERY; + // Starting with Android 6.0 a bluetooth scan requires ACCESS_COARSE_LOCATION permission, but that's not all! + // LESCAN also needs enabled 'location services', whereas DISCOVERY works without. + // Most users think of GPS as 'location service', but it includes more, as we see here. + // Instead of asking the user to enable something they consider unrelated, + // we fall back to the older API that scans for bluetooth classic _and_ LE + // sometimes the older API returns less results or slower + } + scanState = nextScanState; + listItems.clear(); + listAdapter.notifyDataSetChanged(); + setEmptyText(""); + menu.findItem(R.id.ble_scan).setVisible(false); + menu.findItem(R.id.ble_scan_stop).setVisible(true); + if(scanState == ScanState.LE_SCAN) { + leScanStopHandler.postDelayed(leScanStopCallback, LE_SCAN_PERIOD); + new Thread(() -> bluetoothAdapter.startLeScan(null, leScanCallback), "startLeScan") + .start(); // start async to prevent blocking UI, because startLeScan sometimes take some seconds + } else { + bluetoothAdapter.startDiscovery(); + } + } + + @SuppressLint("MissingPermission") + private void updateScan(BluetoothDevice device) { + if(scanState == ScanState.NONE) + return; + BluetoothUtil.Device device2 = new BluetoothUtil.Device(device); // slow getName() only once + int pos = Collections.binarySearch(listItems, device2); + if (pos < 0) { + listItems.add(-pos - 1, device2); + listAdapter.notifyDataSetChanged(); + } + } + + @SuppressLint("MissingPermission") + private void stopScan() { + if(scanState == ScanState.NONE) + return; + setEmptyText(""); + if(menu != null) { + menu.findItem(R.id.ble_scan).setVisible(true); + menu.findItem(R.id.ble_scan_stop).setVisible(false); + } + switch(scanState) { + case LE_SCAN: + leScanStopHandler.removeCallbacks(leScanStopCallback); + bluetoothAdapter.stopLeScan(leScanCallback); + break; + case DISCOVERY: + bluetoothAdapter.cancelDiscovery(); + break; + default: + // already canceled + } + scanState = ScanState.NONE; + + } + + @Override + public void onListItemClick(@NonNull ListView l, @NonNull View v, int position, long id) { + stopScan(); + BluetoothUtil.Device device = listItems.get(position-1); + Bundle args = new Bundle(); + args.putString("device", device.getDevice().getAddress()); + Fragment fragment = new TerminalFragment(); + fragment.setArguments(args); + getFragmentManager().beginTransaction().replace(R.id.fragment, fragment, "terminal").addToBackStack(null).commit(); + } +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java new file mode 100644 index 00000000..45d89f6b --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java @@ -0,0 +1,33 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import android.os.Bundle; +import androidx.fragment.app.FragmentManager; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +public class MainActivity extends AppCompatActivity implements FragmentManager.OnBackStackChangedListener { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + getSupportFragmentManager().addOnBackStackChangedListener(this); + if (savedInstanceState == null) + getSupportFragmentManager().beginTransaction().add(R.id.fragment, new DevicesFragment(), "devices").commit(); + else + onBackStackChanged(); + } + + @Override + public void onBackStackChanged() { + getSupportActionBar().setDisplayHomeAsUpEnabled(getSupportFragmentManager().getBackStackEntryCount()>0); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java new file mode 100644 index 00000000..9be04fca --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java @@ -0,0 +1,11 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import java.util.ArrayDeque; + +interface SerialListener { + void onSerialConnect (); + void onSerialConnectError (Exception e); + void onSerialRead (byte[] data); // socket -> service + void onSerialRead (ArrayDeque datas); // service -> UI thread + void onSerialIoError (Exception e); +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java new file mode 100644 index 00000000..9eb58b97 --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java @@ -0,0 +1,279 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import java.io.IOException; +import java.util.ArrayDeque; + +/** + * create notification and queue serial data while activity is not in the foreground + * use listener chain: SerialSocket -> SerialService -> UI fragment + */ +public class SerialService extends Service implements SerialListener { + + class SerialBinder extends Binder { + SerialService getService() { return SerialService.this; } + } + + private enum QueueType {Connect, ConnectError, Read, IoError} + + private static class QueueItem { + QueueType type; + ArrayDeque datas; + Exception e; + + QueueItem(QueueType type) { this.type=type; if(type==QueueType.Read) init(); } + QueueItem(QueueType type, Exception e) { this.type=type; this.e=e; } + QueueItem(QueueType type, ArrayDeque datas) { this.type=type; this.datas=datas; } + + void init() { datas = new ArrayDeque<>(); } + void add(byte[] data) { datas.add(data); } + } + + private final Handler mainLooper; + private final IBinder binder; + private final ArrayDeque queue1, queue2; + private final QueueItem lastRead; + + private SerialSocket socket; + private SerialListener listener; + private boolean connected; + + /** + * Lifecylce + */ + public SerialService() { + mainLooper = new Handler(Looper.getMainLooper()); + binder = new SerialBinder(); + queue1 = new ArrayDeque<>(); + queue2 = new ArrayDeque<>(); + lastRead = new QueueItem(QueueType.Read); + } + + @Override + public void onDestroy() { + cancelNotification(); + disconnect(); + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + /** + * Api + */ + public void connect(SerialSocket socket) throws IOException { + socket.connect(this); + this.socket = socket; + connected = true; + } + + public void disconnect() { + connected = false; // ignore data,errors while disconnecting + cancelNotification(); + if(socket != null) { + socket.disconnect(); + socket = null; + } + } + + public void write(byte[] data) throws IOException { + if(!connected) + throw new IOException("not connected"); + socket.write(data); + } + + public void attach(SerialListener listener) { + if(Looper.getMainLooper().getThread() != Thread.currentThread()) + throw new IllegalArgumentException("not in main thread"); + cancelNotification(); + // use synchronized() to prevent new items in queue2 + // new items will not be added to queue1 because mainLooper.post and attach() run in main thread + synchronized (this) { + this.listener = listener; + } + for(QueueItem item : queue1) { + switch(item.type) { + case Connect: listener.onSerialConnect (); break; + case ConnectError: listener.onSerialConnectError (item.e); break; + case Read: listener.onSerialRead (item.datas); break; + case IoError: listener.onSerialIoError (item.e); break; + } + } + for(QueueItem item : queue2) { + switch(item.type) { + case Connect: listener.onSerialConnect (); break; + case ConnectError: listener.onSerialConnectError (item.e); break; + case Read: listener.onSerialRead (item.datas); break; + case IoError: listener.onSerialIoError (item.e); break; + } + } + queue1.clear(); + queue2.clear(); + } + + public void detach() { + if(connected) + createNotification(); + // items already in event queue (posted before detach() to mainLooper) will end up in queue1 + // items occurring later, will be moved directly to queue2 + // detach() and mainLooper.post run in the main thread, so all items are caught + listener = null; + } + + private void createNotification() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel nc = new NotificationChannel(Constants.NOTIFICATION_CHANNEL, "Background service", NotificationManager.IMPORTANCE_LOW); + nc.setShowBadge(false); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.createNotificationChannel(nc); + } + Intent disconnectIntent = new Intent() + .setAction(Constants.INTENT_ACTION_DISCONNECT); + Intent restartIntent = new Intent() + .setClassName(this, Constants.INTENT_CLASS_MAIN_ACTIVITY) + .setAction(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER); + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0; + PendingIntent disconnectPendingIntent = PendingIntent.getBroadcast(this, 1, disconnectIntent, flags); + PendingIntent restartPendingIntent = PendingIntent.getActivity(this, 1, restartIntent, flags); + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, Constants.NOTIFICATION_CHANNEL) + .setSmallIcon(R.drawable.ic_notification) + .setColor(getResources().getColor(R.color.colorPrimary)) + .setContentTitle(getResources().getString(R.string.app_name)) + .setContentText(socket != null ? "Connected to "+socket.getName() : "Background Service") + .setContentIntent(restartPendingIntent) + .setOngoing(true) + .addAction(new NotificationCompat.Action(R.drawable.ic_clear_white_24dp, "Disconnect", disconnectPendingIntent)); + // @drawable/ic_notification created with Android Studio -> New -> Image Asset using @color/colorPrimaryDark as background color + // Android < API 21 does not support vectorDrawables in notifications, so both drawables used here, are created as .png instead of .xml + Notification notification = builder.build(); + startForeground(Constants.NOTIFY_MANAGER_START_FOREGROUND_SERVICE, notification); + } + + private void cancelNotification() { + stopForeground(true); + } + + /** + * SerialListener + */ + public void onSerialConnect() { + if(connected) { + synchronized (this) { + if (listener != null) { + mainLooper.post(() -> { + if (listener != null) { + listener.onSerialConnect(); + } else { + queue1.add(new QueueItem(QueueType.Connect)); + } + }); + } else { + queue2.add(new QueueItem(QueueType.Connect)); + } + } + } + } + + public void onSerialConnectError(Exception e) { + if(connected) { + synchronized (this) { + if (listener != null) { + mainLooper.post(() -> { + if (listener != null) { + listener.onSerialConnectError(e); + } else { + queue1.add(new QueueItem(QueueType.ConnectError, e)); + disconnect(); + } + }); + } else { + queue2.add(new QueueItem(QueueType.ConnectError, e)); + disconnect(); + } + } + } + } + + public void onSerialRead(ArrayDeque datas) { throw new UnsupportedOperationException(); } + + /** + * reduce number of UI updates by merging data chunks. + * Data can arrive at hundred chunks per second, but the UI can only + * perform a dozen updates if receiveText already contains much text. + * + * On new data inform UI thread once (1). + * While not consumed (2), add more data (3). + */ + public void onSerialRead(byte[] data) { + if(connected) { + synchronized (this) { + if (listener != null) { + boolean first; + synchronized (lastRead) { + first = lastRead.datas.isEmpty(); // (1) + lastRead.add(data); // (3) + } + if(first) { + mainLooper.post(() -> { + ArrayDeque datas; + synchronized (lastRead) { + datas = lastRead.datas; + lastRead.init(); // (2) + } + if (listener != null) { + listener.onSerialRead(datas); + } else { + queue1.add(new QueueItem(QueueType.Read, datas)); + } + }); + } + } else { + if(queue2.isEmpty() || queue2.getLast().type != QueueType.Read) + queue2.add(new QueueItem(QueueType.Read)); + queue2.getLast().add(data); + } + } + } + } + + public void onSerialIoError(Exception e) { + if(connected) { + synchronized (this) { + if (listener != null) { + mainLooper.post(() -> { + if (listener != null) { + listener.onSerialIoError(e); + } else { + queue1.add(new QueueItem(QueueType.IoError, e)); + disconnect(); + } + }); + } else { + queue2.add(new QueueItem(QueueType.IoError, e)); + disconnect(); + } + } + } + } + +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialSocket.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialSocket.java new file mode 100644 index 00000000..59979bad --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialSocket.java @@ -0,0 +1,643 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.bluetooth.BluetoothProfile; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Build; +import android.util.Log; + +import java.io.IOException; +import java.security.InvalidParameterException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.UUID; + +/** + * wrap BLE communication into socket like class + * - connect, disconnect and write as methods, + * - read + status is returned by SerialListener + */ +@SuppressLint("MissingPermission") // various BluetoothGatt, BluetoothDevice methods +class SerialSocket extends BluetoothGattCallback { + + /** + * delegate device specific behaviour to inner class + */ + private static class DeviceDelegate { + boolean connectCharacteristics(BluetoothGattService s) { return true; } + // following methods only overwritten for Telit devices + void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor d, int status) { /*nop*/ } + void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic c) {/*nop*/ } + void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic c, int status) { /*nop*/ } + boolean canWrite() { return true; } + void disconnect() {/*nop*/ } + } + + private static final UUID BLUETOOTH_LE_CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + private static final UUID BLUETOOTH_LE_CC254X_SERVICE = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb"); + private static final UUID BLUETOOTH_LE_CC254X_CHAR_RW = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); + private static final UUID BLUETOOTH_LE_NRF_SERVICE = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e"); + private static final UUID BLUETOOTH_LE_NRF_CHAR_RW2 = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e"); // read on microbit, write on adafruit + private static final UUID BLUETOOTH_LE_NRF_CHAR_RW3 = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e"); + private static final UUID BLUETOOTH_LE_MICROCHIP_SERVICE = UUID.fromString("49535343-FE7D-4AE5-8FA9-9FAFD205E455"); + private static final UUID BLUETOOTH_LE_MICROCHIP_CHAR_RW = UUID.fromString("49535343-1E4D-4BD9-BA61-23C647249616"); + private static final UUID BLUETOOTH_LE_MICROCHIP_CHAR_W = UUID.fromString("49535343-8841-43F4-A8D4-ECBE34729BB3"); + + // https://play.google.com/store/apps/details?id=com.telit.tiosample + // https://www.telit.com/wp-content/uploads/2017/09/TIO_Implementation_Guide_r6.pdf + private static final UUID BLUETOOTH_LE_TIO_SERVICE = UUID.fromString("0000FEFB-0000-1000-8000-00805F9B34FB"); + private static final UUID BLUETOOTH_LE_TIO_CHAR_TX = UUID.fromString("00000001-0000-1000-8000-008025000000"); // WNR + private static final UUID BLUETOOTH_LE_TIO_CHAR_RX = UUID.fromString("00000002-0000-1000-8000-008025000000"); // N + private static final UUID BLUETOOTH_LE_TIO_CHAR_TX_CREDITS = UUID.fromString("00000003-0000-1000-8000-008025000000"); // W + private static final UUID BLUETOOTH_LE_TIO_CHAR_RX_CREDITS = UUID.fromString("00000004-0000-1000-8000-008025000000"); // I + + private static final int MAX_MTU = 512; // BLE standard does not limit, some BLE 4.2 devices support 251, various source say that Android has max 512 + private static final int DEFAULT_MTU = 23; + private static final String TAG = "SerialSocket"; + + private final ArrayList writeBuffer; + private final IntentFilter pairingIntentFilter; + private final BroadcastReceiver pairingBroadcastReceiver; + private final BroadcastReceiver disconnectBroadcastReceiver; + + private final Context context; + private SerialListener listener; + private DeviceDelegate delegate; + private BluetoothDevice device; + private BluetoothGatt gatt; + private BluetoothGattCharacteristic readCharacteristic, writeCharacteristic; + + private boolean writePending; + private boolean canceled; + private boolean connected; + private int payloadSize = DEFAULT_MTU-3; + + SerialSocket(Context context, BluetoothDevice device) { + if(context instanceof Activity) + throw new InvalidParameterException("expected non UI context"); + this.context = context; + this.device = device; + writeBuffer = new ArrayList<>(); + pairingIntentFilter = new IntentFilter(); + pairingIntentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED); + pairingIntentFilter.addAction(BluetoothDevice.ACTION_PAIRING_REQUEST); + pairingBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + onPairingBroadcastReceive(context, intent); + } + }; + disconnectBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if(listener != null) + listener.onSerialIoError(new IOException("background disconnect")); + disconnect(); // disconnect now, else would be queued until UI re-attached + } + }; + } + + String getName() { + return device.getName() != null ? device.getName() : device.getAddress(); + } + + void disconnect() { + Log.d(TAG, "disconnect"); + listener = null; // ignore remaining data and errors + device = null; + canceled = true; + synchronized (writeBuffer) { + writePending = false; + writeBuffer.clear(); + } + readCharacteristic = null; + writeCharacteristic = null; + if(delegate != null) + delegate.disconnect(); + if (gatt != null) { + Log.d(TAG, "gatt.disconnect"); + gatt.disconnect(); + Log.d(TAG, "gatt.close"); + try { + gatt.close(); + } catch (Exception ignored) {} + gatt = null; + connected = false; + } + try { + context.unregisterReceiver(pairingBroadcastReceiver); + } catch (Exception ignored) { + } + try { + context.unregisterReceiver(disconnectBroadcastReceiver); + } catch (Exception ignored) { + } + } + + /** + * connect-success and most connect-errors are returned asynchronously to listener + */ + void connect(SerialListener listener) throws IOException { + if(connected || gatt != null) + throw new IOException("already connected"); + canceled = false; + this.listener = listener; + context.registerReceiver(disconnectBroadcastReceiver, new IntentFilter(Constants.INTENT_ACTION_DISCONNECT)); + Log.d(TAG, "connect "+device); + context.registerReceiver(pairingBroadcastReceiver, pairingIntentFilter); + if (Build.VERSION.SDK_INT < 23) { + Log.d(TAG, "connectGatt"); + gatt = device.connectGatt(context, false, this); + } else { + Log.d(TAG, "connectGatt,LE"); + gatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE); + } + if (gatt == null) + throw new IOException("connectGatt failed"); + // continues asynchronously in onPairingBroadcastReceive() and onConnectionStateChange() + } + + private void onPairingBroadcastReceive(Context context, Intent intent) { + // for ARM Mbed, Microbit, ... use pairing from Android bluetooth settings + // for HM10-clone, ... pairing is initiated here + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + if(device==null || !device.equals(this.device)) + return; + switch (intent.getAction()) { + case BluetoothDevice.ACTION_PAIRING_REQUEST: + final int pairingVariant = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_VARIANT, -1); + Log.d(TAG, "pairing request " + pairingVariant); + onSerialConnectError(new IOException("Pairing requested: pair and connect again")); + // pairing dialog brings app to background (onPause), but it is still partly visible (no onStop), so there is no automatic disconnect() + break; + case BluetoothDevice.ACTION_BOND_STATE_CHANGED: + final int bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1); + final int previousBondState = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1); + Log.d(TAG, "bond state " + previousBondState + "->" + bondState); + break; + default: + Log.d(TAG, "unknown broadcast " + intent.getAction()); + break; + } + } + + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + // status directly taken from gat_api.h, e.g. 133=0x85=GATT_ERROR ~= timeout + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG,"connect status "+status+", discoverServices"); + if (!gatt.discoverServices()) + onSerialConnectError(new IOException("discoverServices failed")); + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + if (connected) + onSerialIoError (new IOException("gatt status " + status)); + else + onSerialConnectError(new IOException("gatt status " + status)); + } else { + Log.d(TAG, "unknown connect state "+newState+" "+status); + } + // continues asynchronously in onServicesDiscovered() + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + Log.d(TAG, "servicesDiscovered, status " + status); + if (canceled) + return; + connectCharacteristics1(gatt); + } + + private void connectCharacteristics1(BluetoothGatt gatt) { + boolean sync = true; + writePending = false; + for (BluetoothGattService gattService : gatt.getServices()) { + if (gattService.getUuid().equals(BLUETOOTH_LE_CC254X_SERVICE)) + delegate = new Cc245XDelegate(); + if (gattService.getUuid().equals(BLUETOOTH_LE_MICROCHIP_SERVICE)) + delegate = new MicrochipDelegate(); + if (gattService.getUuid().equals(BLUETOOTH_LE_NRF_SERVICE)) + delegate = new NrfDelegate(); + if (gattService.getUuid().equals(BLUETOOTH_LE_TIO_SERVICE)) + delegate = new TelitDelegate(); + + if(delegate != null) { + sync = delegate.connectCharacteristics(gattService); + break; + } + } + if(canceled) + return; + if(delegate==null || readCharacteristic==null || writeCharacteristic==null) { + for (BluetoothGattService gattService : gatt.getServices()) { + Log.d(TAG, "service "+gattService.getUuid()); + for(BluetoothGattCharacteristic characteristic : gattService.getCharacteristics()) + Log.d(TAG, "characteristic "+characteristic.getUuid()); + } + onSerialConnectError(new IOException("no serial profile found")); + return; + } + if(sync) + connectCharacteristics2(gatt); + } + + private void connectCharacteristics2(BluetoothGatt gatt) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Log.d(TAG, "request max MTU"); + if (!gatt.requestMtu(MAX_MTU)) + onSerialConnectError(new IOException("request MTU failed")); + // continues asynchronously in onMtuChanged + } else { + connectCharacteristics3(gatt); + } + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + Log.d(TAG,"mtu size "+mtu+", status="+status); + if(status == BluetoothGatt.GATT_SUCCESS) { + payloadSize = mtu - 3; + Log.d(TAG, "payload size "+payloadSize); + } + connectCharacteristics3(gatt); + } + + private void connectCharacteristics3(BluetoothGatt gatt) { + int writeProperties = writeCharacteristic.getProperties(); + if((writeProperties & (BluetoothGattCharacteristic.PROPERTY_WRITE + // Microbit,HM10-clone have WRITE + BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)) ==0) { // HM10,TI uart,Telit have only WRITE_NO_RESPONSE + onSerialConnectError(new IOException("write characteristic not writable")); + return; + } + if(!gatt.setCharacteristicNotification(readCharacteristic,true)) { + onSerialConnectError(new IOException("no notification for read characteristic")); + return; + } + BluetoothGattDescriptor readDescriptor = readCharacteristic.getDescriptor(BLUETOOTH_LE_CCCD); + if(readDescriptor == null) { + onSerialConnectError(new IOException("no CCCD descriptor for read characteristic")); + return; + } + int readProperties = readCharacteristic.getProperties(); + if((readProperties & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) { + Log.d(TAG, "enable read indication"); + readDescriptor.setValue(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE); + }else if((readProperties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { + Log.d(TAG, "enable read notification"); + readDescriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); + } else { + onSerialConnectError(new IOException("no indication/notification for read characteristic ("+readProperties+")")); + return; + } + Log.d(TAG,"writing read characteristic descriptor"); + if(!gatt.writeDescriptor(readDescriptor)) { + onSerialConnectError(new IOException("read characteristic CCCD descriptor not writable")); + } + // continues asynchronously in onDescriptorWrite() + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + delegate.onDescriptorWrite(gatt, descriptor, status); + if(canceled) + return; + if(descriptor.getCharacteristic() == readCharacteristic) { + Log.d(TAG,"writing read characteristic descriptor finished, status="+status); + if (status != BluetoothGatt.GATT_SUCCESS) { + onSerialConnectError(new IOException("write descriptor failed")); + } else { + // onCharacteristicChanged with incoming data can happen after writeDescriptor(ENABLE_INDICATION/NOTIFICATION) + // before confirmed by this method, so receive data can be shown before device is shown as 'Connected'. + onSerialConnect(); + connected = true; + Log.d(TAG, "connected"); + } + } + } + + /* + * read + */ + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + if(canceled) + return; + delegate.onCharacteristicChanged(gatt, characteristic); + if(canceled) + return; + if(characteristic == readCharacteristic) { // NOPMD - test object identity + byte[] data = readCharacteristic.getValue(); + onSerialRead(data); + Log.d(TAG,"read, len="+data.length); + } + } + + /* + * write + */ + void write(byte[] data) throws IOException { + if(canceled || !connected || writeCharacteristic == null) + throw new IOException("not connected"); + byte[] data0; + synchronized (writeBuffer) { + if(data.length <= payloadSize) { + data0 = data; + } else { + data0 = Arrays.copyOfRange(data, 0, payloadSize); + } + if(!writePending && writeBuffer.isEmpty() && delegate.canWrite()) { + writePending = true; + } else { + writeBuffer.add(data0); + Log.d(TAG,"write queued, len="+data0.length); + data0 = null; + } + if(data.length > payloadSize) { + for(int i=1; i<(data.length+payloadSize-1)/payloadSize; i++) { + int from = i*payloadSize; + int to = Math.min(from+payloadSize, data.length); + writeBuffer.add(Arrays.copyOfRange(data, from, to)); + Log.d(TAG,"write queued, len="+(to-from)); + } + } + } + if(data0 != null) { + writeCharacteristic.setValue(data0); + if (!gatt.writeCharacteristic(writeCharacteristic)) { + onSerialIoError(new IOException("write failed")); + } else { + Log.d(TAG,"write started, len="+data0.length); + } + } + // continues asynchronously in onCharacteristicWrite() + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + if(canceled || !connected || writeCharacteristic == null) + return; + if(status != BluetoothGatt.GATT_SUCCESS) { + onSerialIoError(new IOException("write failed")); + return; + } + delegate.onCharacteristicWrite(gatt, characteristic, status); + if(canceled) + return; + if(characteristic == writeCharacteristic) { // NOPMD - test object identity + Log.d(TAG,"write finished, status="+status); + writeNext(); + } + } + + private void writeNext() { + final byte[] data; + synchronized (writeBuffer) { + if (!writeBuffer.isEmpty() && delegate.canWrite()) { + writePending = true; + data = writeBuffer.remove(0); + } else { + writePending = false; + data = null; + } + } + if(data != null) { + writeCharacteristic.setValue(data); + if (!gatt.writeCharacteristic(writeCharacteristic)) { + onSerialIoError(new IOException("write failed")); + } else { + Log.d(TAG,"write started, len="+data.length); + } + } + } + + /** + * SerialListener + */ + private void onSerialConnect() { + if (listener != null) + listener.onSerialConnect(); + } + + private void onSerialConnectError(Exception e) { + canceled = true; + if (listener != null) + listener.onSerialConnectError(e); + } + + private void onSerialRead(byte[] data) { + if (listener != null) + listener.onSerialRead(data); + } + + private void onSerialIoError(Exception e) { + writePending = false; + canceled = true; + if (listener != null) + listener.onSerialIoError(e); + } + + /** + * device delegates + */ + + private class Cc245XDelegate extends DeviceDelegate { + @Override + boolean connectCharacteristics(BluetoothGattService gattService) { + Log.d(TAG, "service cc254x uart"); + readCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_CC254X_CHAR_RW); + writeCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_CC254X_CHAR_RW); + return true; + } + } + + private class MicrochipDelegate extends DeviceDelegate { + @Override + boolean connectCharacteristics(BluetoothGattService gattService) { + Log.d(TAG, "service microchip uart"); + readCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_MICROCHIP_CHAR_RW); + writeCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_MICROCHIP_CHAR_W); + if(writeCharacteristic == null) + writeCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_MICROCHIP_CHAR_RW); + return true; + } + } + + private class NrfDelegate extends DeviceDelegate { + @Override + boolean connectCharacteristics(BluetoothGattService gattService) { + Log.d(TAG, "service nrf uart"); + BluetoothGattCharacteristic rw2 = gattService.getCharacteristic(BLUETOOTH_LE_NRF_CHAR_RW2); + BluetoothGattCharacteristic rw3 = gattService.getCharacteristic(BLUETOOTH_LE_NRF_CHAR_RW3); + if (rw2 != null && rw3 != null) { + int rw2prop = rw2.getProperties(); + int rw3prop = rw3.getProperties(); + boolean rw2write = (rw2prop & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0; + boolean rw3write = (rw3prop & BluetoothGattCharacteristic.PROPERTY_WRITE) != 0; + Log.d(TAG, "characteristic properties " + rw2prop + "/" + rw3prop); + if (rw2write && rw3write) { + onSerialConnectError(new IOException("multiple write characteristics (" + rw2prop + "/" + rw3prop + ")")); + } else if (rw2write) { + writeCharacteristic = rw2; + readCharacteristic = rw3; + } else if (rw3write) { + writeCharacteristic = rw3; + readCharacteristic = rw2; + } else { + onSerialConnectError(new IOException("no write characteristic (" + rw2prop + "/" + rw3prop + ")")); + } + } + return true; + } + } + + private class TelitDelegate extends DeviceDelegate { + private BluetoothGattCharacteristic readCreditsCharacteristic, writeCreditsCharacteristic; + private int readCredits, writeCredits; + + @Override + boolean connectCharacteristics(BluetoothGattService gattService) { + Log.d(TAG, "service telit tio 2.0"); + readCredits = 0; + writeCredits = 0; + readCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_RX); + writeCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_TX); + readCreditsCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_RX_CREDITS); + writeCreditsCharacteristic = gattService.getCharacteristic(BLUETOOTH_LE_TIO_CHAR_TX_CREDITS); + if (readCharacteristic == null) { + onSerialConnectError(new IOException("read characteristic not found")); + return false; + } + if (writeCharacteristic == null) { + onSerialConnectError(new IOException("write characteristic not found")); + return false; + } + if (readCreditsCharacteristic == null) { + onSerialConnectError(new IOException("read credits characteristic not found")); + return false; + } + if (writeCreditsCharacteristic == null) { + onSerialConnectError(new IOException("write credits characteristic not found")); + return false; + } + if (!gatt.setCharacteristicNotification(readCreditsCharacteristic, true)) { + onSerialConnectError(new IOException("no notification for read credits characteristic")); + return false; + } + BluetoothGattDescriptor readCreditsDescriptor = readCreditsCharacteristic.getDescriptor(BLUETOOTH_LE_CCCD); + if (readCreditsDescriptor == null) { + onSerialConnectError(new IOException("no CCCD descriptor for read credits characteristic")); + return false; + } + readCreditsDescriptor.setValue(BluetoothGattDescriptor.ENABLE_INDICATION_VALUE); + Log.d(TAG,"writing read credits characteristic descriptor"); + if (!gatt.writeDescriptor(readCreditsDescriptor)) { + onSerialConnectError(new IOException("read credits characteristic CCCD descriptor not writable")); + return false; + } + Log.d(TAG, "writing read credits characteristic descriptor"); + return false; + // continues asynchronously in connectCharacteristics2 + } + + @Override + void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + if(descriptor.getCharacteristic() == readCreditsCharacteristic) { + Log.d(TAG, "writing read credits characteristic descriptor finished, status=" + status); + if (status != BluetoothGatt.GATT_SUCCESS) { + onSerialConnectError(new IOException("write credits descriptor failed")); + } else { + connectCharacteristics2(gatt); + } + } + if(descriptor.getCharacteristic() == readCharacteristic) { + Log.d(TAG, "writing read characteristic descriptor finished, status=" + status); + if (status == BluetoothGatt.GATT_SUCCESS) { + readCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); + writeCharacteristic.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE); + grantReadCredits(); + // grantReadCredits includes gatt.writeCharacteristic(writeCreditsCharacteristic) + // but we do not have to wait for confirmation, as it is the last write of connect phase. + } + } + } + + @Override + void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + if(characteristic == readCreditsCharacteristic) { // NOPMD - test object identity + int newCredits = readCreditsCharacteristic.getValue()[0]; + synchronized (writeBuffer) { + writeCredits += newCredits; + } + Log.d(TAG, "got write credits +"+newCredits+" ="+writeCredits); + + if (!writePending && !writeBuffer.isEmpty()) { + Log.d(TAG, "resume blocked write"); + writeNext(); + } + } + if(characteristic == readCharacteristic) { // NOPMD - test object identity + grantReadCredits(); + Log.d(TAG, "read, credits=" + readCredits); + } + } + + @Override + void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + if(characteristic == writeCharacteristic) { // NOPMD - test object identity + synchronized (writeBuffer) { + if (writeCredits > 0) + writeCredits -= 1; + } + Log.d(TAG, "write finished, credits=" + writeCredits); + } + if(characteristic == writeCreditsCharacteristic) { // NOPMD - test object identity + Log.d(TAG,"write credits finished, status="+status); + } + } + + @Override + boolean canWrite() { + if(writeCredits > 0) + return true; + Log.d(TAG, "no write credits"); + return false; + } + + @Override + void disconnect() { + readCreditsCharacteristic = null; + writeCreditsCharacteristic = null; + } + + private void grantReadCredits() { + final int minReadCredits = 16; + final int maxReadCredits = 64; + if(readCredits > 0) + readCredits -= 1; + if(readCredits <= minReadCredits) { + int newCredits = maxReadCredits - readCredits; + readCredits += newCredits; + byte[] data = new byte[] {(byte)newCredits}; + Log.d(TAG, "grant read credits +"+newCredits+" ="+readCredits); + writeCreditsCharacteristic.setValue(data); + if (!gatt.writeCharacteristic(writeCreditsCharacteristic)) { + if(connected) + onSerialIoError(new IOException("write read credits failed")); + else + onSerialConnectError(new IOException("write read credits failed")); + } + } + } + + } + +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java new file mode 100644 index 00000000..612a9a23 --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java @@ -0,0 +1,293 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import android.app.Activity; +import android.app.AlertDialog; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.text.Editable; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.method.ScrollingMovementMethod; +import android.text.style.ForegroundColorSpan; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import java.util.ArrayDeque; + +public class TerminalFragment extends Fragment implements ServiceConnection, SerialListener { + + private enum Connected { False, Pending, True } + + private String deviceAddress; + private SerialService service; + + private TextView receiveText; + private TextView sendText; + private TextUtil.HexWatcher hexWatcher; + + private Connected connected = Connected.False; + private boolean initialStart = true; + private boolean hexEnabled = false; + private boolean pendingNewline = false; + private String newline = TextUtil.newline_crlf; + + /* + * Lifecycle + */ + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + setRetainInstance(true); + deviceAddress = getArguments().getString("device"); + } + + @Override + public void onDestroy() { + if (connected != Connected.False) + disconnect(); + getActivity().stopService(new Intent(getActivity(), SerialService.class)); + super.onDestroy(); + } + + @Override + public void onStart() { + super.onStart(); + if(service != null) + service.attach(this); + else + getActivity().startService(new Intent(getActivity(), SerialService.class)); // prevents service destroy on unbind from recreated activity caused by orientation change + } + + @Override + public void onStop() { + if(service != null && !getActivity().isChangingConfigurations()) + service.detach(); + super.onStop(); + } + + @SuppressWarnings("deprecation") // onAttach(context) was added with API 23. onAttach(activity) works for all API versions + @Override + public void onAttach(@NonNull Activity activity) { + super.onAttach(activity); + getActivity().bindService(new Intent(getActivity(), SerialService.class), this, Context.BIND_AUTO_CREATE); + } + + @Override + public void onDetach() { + try { getActivity().unbindService(this); } catch(Exception ignored) {} + super.onDetach(); + } + + @Override + public void onResume() { + super.onResume(); + if(initialStart && service != null) { + initialStart = false; + getActivity().runOnUiThread(this::connect); + } + } + + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + service = ((SerialService.SerialBinder) binder).getService(); + service.attach(this); + if(initialStart && isResumed()) { + initialStart = false; + getActivity().runOnUiThread(this::connect); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + service = null; + } + + /* + * UI + */ + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_terminal, container, false); + receiveText = view.findViewById(R.id.receive_text); // TextView performance decreases with number of spans + receiveText.setTextColor(getResources().getColor(R.color.colorRecieveText)); // set as default color to reduce number of spans + receiveText.setMovementMethod(ScrollingMovementMethod.getInstance()); + + sendText = view.findViewById(R.id.send_text); + hexWatcher = new TextUtil.HexWatcher(sendText); + hexWatcher.enable(hexEnabled); + sendText.addTextChangedListener(hexWatcher); + sendText.setHint(hexEnabled ? "HEX mode" : ""); + + View sendBtn = view.findViewById(R.id.send_btn); + sendBtn.setOnClickListener(v -> send(sendText.getText().toString())); + return view; + } + + @Override + public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_terminal, menu); + menu.findItem(R.id.hex).setChecked(hexEnabled); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + if (id == R.id.clear) { + receiveText.setText(""); + return true; + } else if (id == R.id.newline) { + String[] newlineNames = getResources().getStringArray(R.array.newline_names); + String[] newlineValues = getResources().getStringArray(R.array.newline_values); + int pos = java.util.Arrays.asList(newlineValues).indexOf(newline); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle("Newline"); + builder.setSingleChoiceItems(newlineNames, pos, (dialog, item1) -> { + newline = newlineValues[item1]; + dialog.dismiss(); + }); + builder.create().show(); + return true; + } else if (id == R.id.hex) { + hexEnabled = !hexEnabled; + sendText.setText(""); + hexWatcher.enable(hexEnabled); + sendText.setHint(hexEnabled ? "HEX mode" : ""); + item.setChecked(hexEnabled); + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + /* + * Serial + UI + */ + private void connect() { + try { + BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + BluetoothDevice device = bluetoothAdapter.getRemoteDevice(deviceAddress); + status("connecting..."); + connected = Connected.Pending; + SerialSocket socket = new SerialSocket(getActivity().getApplicationContext(), device); + service.connect(socket); + } catch (Exception e) { + onSerialConnectError(e); + } + } + + private void disconnect() { + connected = Connected.False; + service.disconnect(); + } + + private void send(String str) { + if(connected != Connected.True) { + Toast.makeText(getActivity(), "not connected", Toast.LENGTH_SHORT).show(); + return; + } + try { + String msg; + byte[] data; + if(hexEnabled) { + StringBuilder sb = new StringBuilder(); + TextUtil.toHexString(sb, TextUtil.fromHexString(str)); + TextUtil.toHexString(sb, newline.getBytes()); + msg = sb.toString(); + data = TextUtil.fromHexString(msg); + } else { + msg = str; + data = (str + newline).getBytes(); + } + SpannableStringBuilder spn = new SpannableStringBuilder(msg + '\n'); + spn.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.colorSendText)), 0, spn.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + receiveText.append(spn); + service.write(data); + } catch (Exception e) { + onSerialIoError(e); + } + } + + private void receive(ArrayDeque datas) { + SpannableStringBuilder spn = new SpannableStringBuilder(); + for (byte[] data : datas) { + if (hexEnabled) { + spn.append(TextUtil.toHexString(data)).append('\n'); + } else { + String msg = new String(data); + if (newline.equals(TextUtil.newline_crlf) && msg.length() > 0) { + // don't show CR as ^M if directly before LF + msg = msg.replace(TextUtil.newline_crlf, TextUtil.newline_lf); + // special handling if CR and LF come in separate fragments + if (pendingNewline && msg.charAt(0) == '\n') { + if(spn.length() >= 2) { + spn.delete(spn.length() - 2, spn.length()); + } else { + Editable edt = receiveText.getEditableText(); + if (edt != null && edt.length() >= 2) + edt.delete(edt.length() - 2, edt.length()); + } + } + pendingNewline = msg.charAt(msg.length() - 1) == '\r'; + } + spn.append(TextUtil.toCaretString(msg, newline.length() != 0)); + } + } + receiveText.append(spn); + } + + private void status(String str) { + SpannableStringBuilder spn = new SpannableStringBuilder(str + '\n'); + spn.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.colorStatusText)), 0, spn.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + receiveText.append(spn); + } + + /* + * SerialListener + */ + @Override + public void onSerialConnect() { + status("connected"); + connected = Connected.True; + } + + @Override + public void onSerialConnectError(Exception e) { + status("connection failed: " + e.getMessage()); + disconnect(); + } + + @Override + public void onSerialRead(byte[] data) { + ArrayDeque datas = new ArrayDeque<>(); + datas.add(data); + receive(datas); + } + + public void onSerialRead(ArrayDeque datas) { + receive(datas); + } + + @Override + public void onSerialIoError(Exception e) { + status("connection lost: " + e.getMessage()); + disconnect(); + } + +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TextUtil.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TextUtil.java new file mode 100644 index 00000000..dac5b16d --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TextUtil.java @@ -0,0 +1,155 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import android.text.Editable; +import android.text.InputType; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextWatcher; +import android.text.style.BackgroundColorSpan; +import android.widget.TextView; + +import androidx.annotation.ColorInt; + +import java.io.ByteArrayOutputStream; + +final class TextUtil { + + @ColorInt static int caretBackground = 0xff666666; + + final static String newline_crlf = "\r\n"; + final static String newline_lf = "\n"; + + static byte[] fromHexString(final CharSequence s) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte b = 0; + int nibble = 0; + for(int pos = 0; pos='0' && c<='9') { nibble++; b *= 16; b += c-'0'; } + if(c>='A' && c<='F') { nibble++; b *= 16; b += c-'A'+10; } + if(c>='a' && c<='f') { nibble++; b *= 16; b += c-'a'+10; } + } + if(nibble>0) + buf.write(b); + return buf.toByteArray(); + } + + static String toHexString(final byte[] buf) { + return toHexString(buf, 0, buf.length); + } + + static String toHexString(final byte[] buf, int begin, int end) { + StringBuilder sb = new StringBuilder(3*(end-begin)); + toHexString(sb, buf, begin, end); + return sb.toString(); + } + + static void toHexString(StringBuilder sb, final byte[] buf) { + toHexString(sb, buf, 0, buf.length); + } + + static void toHexString(StringBuilder sb, final byte[] buf, int begin, int end) { + for(int pos=begin; pos0) + sb.append(' '); + int c; + c = (buf[pos]&0xff) / 16; + if(c >= 10) c += 'A'-10; + else c += '0'; + sb.append((char)c); + c = (buf[pos]&0xff) % 16; + if(c >= 10) c += 'A'-10; + else c += '0'; + sb.append((char)c); + } + } + + /** + * use https://en.wikipedia.org/wiki/Caret_notation to avoid invisible control characters + */ + static CharSequence toCaretString(CharSequence s, boolean keepNewline) { + return toCaretString(s, keepNewline, s.length()); + } + + static CharSequence toCaretString(CharSequence s, boolean keepNewline, int length) { + boolean found = false; + for (int pos = 0; pos < length; pos++) { + if (s.charAt(pos) < 32 && (!keepNewline ||s.charAt(pos)!='\n')) { + found = true; + break; + } + } + if(!found) + return s; + SpannableStringBuilder sb = new SpannableStringBuilder(); + for(int pos=0; pos= '0' && c <= '9') sb.append(c); + if(c >= 'A' && c <= 'F') sb.append(c); + if(c >= 'a' && c <= 'f') sb.append((char)(c+'A'-'a')); + } + for(i=2; i Date: Wed, 21 Jun 2023 23:49:28 -0400 Subject: [PATCH 03/13] BROKEN Still broken --- .../FlutterBluetoothSerialPlugin.java | 6 +-- .../le/BluetoothConnectionLE.java | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java index 46c403b4..19468bc8 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java @@ -70,7 +70,7 @@ public class FlutterBluetoothSerialPlugin implements FlutterPlugin, ActivityAwar // Connections /// Contains all active connections. Maps ID of the connection with plugin data channels. - private final SparseArray connections = new SparseArray<>(2); + private final SparseArray connections = new SparseArray<>(2); /// Last ID given to any connection, used to avoid duplicate IDs private int lastConnectionId = 0; @@ -615,7 +615,7 @@ public void onMethodCall(MethodCall call, Result result) { break; case "getAddress": { - String address = bluetoothAdapter.getAddress(); + @SuppressLint({"MissingPermission", "HardwareIds"}) String address = bluetoothAdapter.getAddress(); if (address.equals("02:00:00:00:00:00")) { Log.w(TAG, "Local Bluetooth MAC address is hidden by system, trying other options..."); @@ -1006,7 +1006,7 @@ public void onReceive(Context context, Intent intent) { } int id = ++lastConnectionId; - BluetoothConnectionWrapper connection = new BluetoothConnectionWrapper(id, bluetoothAdapter); + BluetoothConnection connection = new BluetoothConnectionWrapper(id, bluetoothAdapter); connections.put(id, connection); Log.d(TAG, "Connecting to " + address + " (id: " + id + ")"); diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java new file mode 100644 index 00000000..d99086a6 --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java @@ -0,0 +1,43 @@ +package io.github.edufolly.flutterbluetoothserial.le; + +import java.io.IOException; +import java.util.UUID; + +import io.github.edufolly.flutterbluetoothserial.BluetoothConnection; + +public class BluetoothConnectionLE implements BluetoothConnection { + @Override + public boolean isConnected() { + asdf; + } + + @Override + public void connect(String address, UUID uuid) throws IOException { + + } + + @Override + public void connect(String address) throws IOException { + + } + + @Override + public void disconnect() { + + } + + @Override + public void write(byte[] data) throws IOException { + + } + + @Override + public void onRead(byte[] data) { + + } + + @Override + public void onDisconnected(boolean byRemote) { + + } +} From 7d6f2e4f35965a6c0520544a13ffd65d054b744c Mon Sep 17 00:00:00 2001 From: erhannis Date: Wed, 28 Jun 2023 00:13:34 -0400 Subject: [PATCH 04/13] BROKEN Working on integration --- .../le/BluetoothConnectionLE.java | 53 +++- .../le/BluetoothUtil.java | 12 +- .../le/DevicesFragment.java | 4 +- .../le/MainActivity.java | 33 -- .../le/TerminalFragment.java | 293 ------------------ 5 files changed, 49 insertions(+), 346 deletions(-) delete mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java delete mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java index d99086a6..fc79deba 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java @@ -1,43 +1,70 @@ package io.github.edufolly.flutterbluetoothserial.le; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; + import java.io.IOException; import java.util.UUID; import io.github.edufolly.flutterbluetoothserial.BluetoothConnection; -public class BluetoothConnectionLE implements BluetoothConnection { +public abstract class BluetoothConnectionLE implements BluetoothConnection { + private enum Connected { False, Pending, True } + + private Connected connected = Connected.False; + private SerialSocket socket; + @Override public boolean isConnected() { - asdf; + return connected == Connected.True; } @Override public void connect(String address, UUID uuid) throws IOException { - + connect(address); // Ignore the uuid, not used } @Override public void connect(String address) throws IOException { + if (isConnected()) { + throw new IOException("already connected"); + } + try { + BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + BluetoothDevice device = bluetoothAdapter.getRemoteDevice(address); + status("connecting..."); + connected = Connected.Pending; + SerialSocket socket = new SerialSocket(getActivity().getApplicationContext(), device); + socket.connect(this); + this.socket = socket; + connected = Connected.True; + } catch (Exception e) { + onSerialConnectError(e); + } } @Override public void disconnect() { - + if (isConnected()) { + connected = Connected.False; // ignore data,errors while disconnecting + cancelNotification(); + if (socket != null) { + socket.disconnect(); + socket = null; + } + } } @Override public void write(byte[] data) throws IOException { - + if (!isConnected()) { + throw new IOException("not connected"); + } + socket.write(data); } - @Override - public void onRead(byte[] data) { - - } + public abstract void onRead(byte[] data); - @Override - public void onDisconnected(boolean byRemote) { - - } + public abstract void onDisconnected(boolean byRemote); } diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java index 08af1bcf..b73ff570 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothUtil.java @@ -15,6 +15,8 @@ import java.util.Map; +import io.github.edufolly.flutterbluetoothserial.BuildConfig; + public class BluetoothUtil { interface PermissionGrantedCallback { @@ -69,8 +71,8 @@ public int compareTo(Device other) { */ private static void showRationaleDialog(Fragment fragment, DialogInterface.OnClickListener listener) { final AlertDialog.Builder builder = new AlertDialog.Builder(fragment.getActivity()); - builder.setTitle(fragment.getString(R.string.bluetooth_permission_title)); - builder.setMessage(fragment.getString(R.string.bluetooth_permission_grant)); + builder.setTitle("bluetooth_permission_title"); //DUMMY + builder.setMessage("bluetooth_permission_grant"); //DUMMY builder.setNegativeButton("Cancel", null); builder.setPositiveButton("Continue", listener); builder.show(); @@ -79,12 +81,12 @@ private static void showRationaleDialog(Fragment fragment, DialogInterface.OnCli private static void showSettingsDialog(Fragment fragment) { String s = fragment.getResources().getString(fragment.getResources().getIdentifier("@android:string/permgrouplab_nearby_devices", null, null)); final AlertDialog.Builder builder = new AlertDialog.Builder(fragment.getActivity()); - builder.setTitle(fragment.getString(R.string.bluetooth_permission_title)); - builder.setMessage(String.format(fragment.getString(R.string.bluetooth_permission_denied), s)); + builder.setTitle("bluetooth_permission_title"); + builder.setMessage("bluetooth_permission_denied : " + s); builder.setNegativeButton("Cancel", null); builder.setPositiveButton("Settings", (dialog, which) -> fragment.startActivity(new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.parse("package:" + BuildConfig.APPLICATION_ID)))); + Uri.parse("package:" + BuildConfig.LIBRARY_PACKAGE_NAME)))); //DUMMY Not sure about this one builder.show(); } diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java index 53aa28d8..f1ec2a88 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java @@ -90,8 +90,8 @@ public void onReceive(Context context, Intent intent) { new Handler(Looper.getMainLooper()).postDelayed(this::startScan, 1); // run after onResume to avoid wrong empty-text } else { AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(getText(R.string.location_permission_title)); - builder.setMessage(getText(R.string.location_permission_denied)); + builder.setTitle("location_permission_title"); //DUMMY + builder.setMessage("location_permission_denied"); //DUMMY builder.setPositiveButton(android.R.string.ok, null); builder.show(); } diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java deleted file mode 100644 index 45d89f6b..00000000 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/MainActivity.java +++ /dev/null @@ -1,33 +0,0 @@ -package io.github.edufolly.flutterbluetoothserial.le; - -import android.os.Bundle; -import androidx.fragment.app.FragmentManager; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; - -public class MainActivity extends AppCompatActivity implements FragmentManager.OnBackStackChangedListener { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - getSupportFragmentManager().addOnBackStackChangedListener(this); - if (savedInstanceState == null) - getSupportFragmentManager().beginTransaction().add(R.id.fragment, new DevicesFragment(), "devices").commit(); - else - onBackStackChanged(); - } - - @Override - public void onBackStackChanged() { - getSupportActionBar().setDisplayHomeAsUpEnabled(getSupportFragmentManager().getBackStackEntryCount()>0); - } - - @Override - public boolean onSupportNavigateUp() { - onBackPressed(); - return true; - } -} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java deleted file mode 100644 index 612a9a23..00000000 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/TerminalFragment.java +++ /dev/null @@ -1,293 +0,0 @@ -package io.github.edufolly.flutterbluetoothserial.le; - -import android.app.Activity; -import android.app.AlertDialog; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.os.Bundle; -import android.os.IBinder; -import android.text.Editable; -import android.text.Spannable; -import android.text.SpannableStringBuilder; -import android.text.method.ScrollingMovementMethod; -import android.text.style.ForegroundColorSpan; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import java.util.ArrayDeque; - -public class TerminalFragment extends Fragment implements ServiceConnection, SerialListener { - - private enum Connected { False, Pending, True } - - private String deviceAddress; - private SerialService service; - - private TextView receiveText; - private TextView sendText; - private TextUtil.HexWatcher hexWatcher; - - private Connected connected = Connected.False; - private boolean initialStart = true; - private boolean hexEnabled = false; - private boolean pendingNewline = false; - private String newline = TextUtil.newline_crlf; - - /* - * Lifecycle - */ - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - setRetainInstance(true); - deviceAddress = getArguments().getString("device"); - } - - @Override - public void onDestroy() { - if (connected != Connected.False) - disconnect(); - getActivity().stopService(new Intent(getActivity(), SerialService.class)); - super.onDestroy(); - } - - @Override - public void onStart() { - super.onStart(); - if(service != null) - service.attach(this); - else - getActivity().startService(new Intent(getActivity(), SerialService.class)); // prevents service destroy on unbind from recreated activity caused by orientation change - } - - @Override - public void onStop() { - if(service != null && !getActivity().isChangingConfigurations()) - service.detach(); - super.onStop(); - } - - @SuppressWarnings("deprecation") // onAttach(context) was added with API 23. onAttach(activity) works for all API versions - @Override - public void onAttach(@NonNull Activity activity) { - super.onAttach(activity); - getActivity().bindService(new Intent(getActivity(), SerialService.class), this, Context.BIND_AUTO_CREATE); - } - - @Override - public void onDetach() { - try { getActivity().unbindService(this); } catch(Exception ignored) {} - super.onDetach(); - } - - @Override - public void onResume() { - super.onResume(); - if(initialStart && service != null) { - initialStart = false; - getActivity().runOnUiThread(this::connect); - } - } - - @Override - public void onServiceConnected(ComponentName name, IBinder binder) { - service = ((SerialService.SerialBinder) binder).getService(); - service.attach(this); - if(initialStart && isResumed()) { - initialStart = false; - getActivity().runOnUiThread(this::connect); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - service = null; - } - - /* - * UI - */ - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_terminal, container, false); - receiveText = view.findViewById(R.id.receive_text); // TextView performance decreases with number of spans - receiveText.setTextColor(getResources().getColor(R.color.colorRecieveText)); // set as default color to reduce number of spans - receiveText.setMovementMethod(ScrollingMovementMethod.getInstance()); - - sendText = view.findViewById(R.id.send_text); - hexWatcher = new TextUtil.HexWatcher(sendText); - hexWatcher.enable(hexEnabled); - sendText.addTextChangedListener(hexWatcher); - sendText.setHint(hexEnabled ? "HEX mode" : ""); - - View sendBtn = view.findViewById(R.id.send_btn); - sendBtn.setOnClickListener(v -> send(sendText.getText().toString())); - return view; - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_terminal, menu); - menu.findItem(R.id.hex).setChecked(hexEnabled); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int id = item.getItemId(); - if (id == R.id.clear) { - receiveText.setText(""); - return true; - } else if (id == R.id.newline) { - String[] newlineNames = getResources().getStringArray(R.array.newline_names); - String[] newlineValues = getResources().getStringArray(R.array.newline_values); - int pos = java.util.Arrays.asList(newlineValues).indexOf(newline); - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle("Newline"); - builder.setSingleChoiceItems(newlineNames, pos, (dialog, item1) -> { - newline = newlineValues[item1]; - dialog.dismiss(); - }); - builder.create().show(); - return true; - } else if (id == R.id.hex) { - hexEnabled = !hexEnabled; - sendText.setText(""); - hexWatcher.enable(hexEnabled); - sendText.setHint(hexEnabled ? "HEX mode" : ""); - item.setChecked(hexEnabled); - return true; - } else { - return super.onOptionsItemSelected(item); - } - } - - /* - * Serial + UI - */ - private void connect() { - try { - BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); - BluetoothDevice device = bluetoothAdapter.getRemoteDevice(deviceAddress); - status("connecting..."); - connected = Connected.Pending; - SerialSocket socket = new SerialSocket(getActivity().getApplicationContext(), device); - service.connect(socket); - } catch (Exception e) { - onSerialConnectError(e); - } - } - - private void disconnect() { - connected = Connected.False; - service.disconnect(); - } - - private void send(String str) { - if(connected != Connected.True) { - Toast.makeText(getActivity(), "not connected", Toast.LENGTH_SHORT).show(); - return; - } - try { - String msg; - byte[] data; - if(hexEnabled) { - StringBuilder sb = new StringBuilder(); - TextUtil.toHexString(sb, TextUtil.fromHexString(str)); - TextUtil.toHexString(sb, newline.getBytes()); - msg = sb.toString(); - data = TextUtil.fromHexString(msg); - } else { - msg = str; - data = (str + newline).getBytes(); - } - SpannableStringBuilder spn = new SpannableStringBuilder(msg + '\n'); - spn.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.colorSendText)), 0, spn.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - receiveText.append(spn); - service.write(data); - } catch (Exception e) { - onSerialIoError(e); - } - } - - private void receive(ArrayDeque datas) { - SpannableStringBuilder spn = new SpannableStringBuilder(); - for (byte[] data : datas) { - if (hexEnabled) { - spn.append(TextUtil.toHexString(data)).append('\n'); - } else { - String msg = new String(data); - if (newline.equals(TextUtil.newline_crlf) && msg.length() > 0) { - // don't show CR as ^M if directly before LF - msg = msg.replace(TextUtil.newline_crlf, TextUtil.newline_lf); - // special handling if CR and LF come in separate fragments - if (pendingNewline && msg.charAt(0) == '\n') { - if(spn.length() >= 2) { - spn.delete(spn.length() - 2, spn.length()); - } else { - Editable edt = receiveText.getEditableText(); - if (edt != null && edt.length() >= 2) - edt.delete(edt.length() - 2, edt.length()); - } - } - pendingNewline = msg.charAt(msg.length() - 1) == '\r'; - } - spn.append(TextUtil.toCaretString(msg, newline.length() != 0)); - } - } - receiveText.append(spn); - } - - private void status(String str) { - SpannableStringBuilder spn = new SpannableStringBuilder(str + '\n'); - spn.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.colorStatusText)), 0, spn.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - receiveText.append(spn); - } - - /* - * SerialListener - */ - @Override - public void onSerialConnect() { - status("connected"); - connected = Connected.True; - } - - @Override - public void onSerialConnectError(Exception e) { - status("connection failed: " + e.getMessage()); - disconnect(); - } - - @Override - public void onSerialRead(byte[] data) { - ArrayDeque datas = new ArrayDeque<>(); - datas.add(data); - receive(datas); - } - - public void onSerialRead(ArrayDeque datas) { - receive(datas); - } - - @Override - public void onSerialIoError(Exception e) { - status("connection lost: " + e.getMessage()); - disconnect(); - } - -} From bb9b1872d02964c9ef787973bdecc096b1551133 Mon Sep 17 00:00:00 2001 From: erhannis Date: Wed, 28 Jun 2023 16:16:35 -0400 Subject: [PATCH 05/13] BROKEN Working on integration --- .../BluetoothConnection.java | 2 +- .../BluetoothConnectionBase.java | 26 ++++ .../BluetoothConnectionClassic.java | 11 +- .../FlutterBluetoothSerialPlugin.java | 136 +++++++++--------- .../le/BluetoothConnectionLE.java | 11 +- .../le/SerialListener.java | 2 +- 6 files changed, 107 insertions(+), 81 deletions(-) create mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java index 85558ca3..2c34c3b2 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnection.java @@ -17,4 +17,4 @@ public interface BluetoothConnection { public void onRead(byte[] data); /// Callback for disconnection. public void onDisconnected(boolean byRemote); - } +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java new file mode 100644 index 00000000..adda3202 --- /dev/null +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java @@ -0,0 +1,26 @@ +package io.github.edufolly.flutterbluetoothserial; + +public abstract class BluetoothConnectionBase implements BluetoothConnection { + public interface OnReadCallback { + public void onRead(byte[] data); + } + public interface OnDisconnectedCallback { + public void onDisconnected(boolean byRemote); + } + + final OnReadCallback onReadCallback; + final OnDisconnectedCallback onDisconnectedCallback; + + public BluetoothConnectionBase(OnReadCallback onReadCallback, OnDisconnectedCallback onDisconnectedCallback) { + this.onReadCallback = onReadCallback; + this.onDisconnectedCallback = onDisconnectedCallback; + } + + public void onRead(byte[] data) { + onReadCallback.onRead(data); + } + + public void onDisconnected(boolean byRemote) { + onDisconnectedCallback.onDisconnected(byRemote); + } +} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java index 625307f7..c3f75def 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionClassic.java @@ -5,13 +5,14 @@ import java.io.OutputStream; import java.util.UUID; import java.util.Arrays; +import java.util.function.Consumer; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothSocket; /// Universal Bluetooth serial connection class (for Java) -public abstract class BluetoothConnectionClassic implements BluetoothConnection +public class BluetoothConnectionClassic extends BluetoothConnectionBase { protected static final UUID DEFAULT_UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"); @@ -25,7 +26,8 @@ public boolean isConnected() { - public BluetoothConnectionClassic(BluetoothAdapter bluetoothAdapter) { + public BluetoothConnectionClassic(OnReadCallback onReadCallback, OnDisconnectedCallback onDisconnectedCallback, BluetoothAdapter bluetoothAdapter) { + super(onReadCallback, onDisconnectedCallback); this.bluetoothAdapter = bluetoothAdapter; } @@ -78,11 +80,6 @@ public void write(byte[] data) throws IOException { connectionThread.write(data); } - public abstract void onRead(byte[] data); - - /// Callback for disconnection. - public abstract void onDisconnected(boolean byRemote); - /// Thread to handle connection I/O private class ConnectionThread extends Thread { private final BluetoothSocket socket; diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java index 19468bc8..2e645b55 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java @@ -40,6 +40,7 @@ import io.flutter.plugin.common.MethodChannel; import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; +import io.github.edufolly.flutterbluetoothserial.le.BluetoothConnectionLE; public class FlutterBluetoothSerialPlugin implements FlutterPlugin, ActivityAware { // Plugin @@ -490,72 +491,6 @@ static private boolean checkIsDeviceConnected(BluetoothDevice device) { } } - - /// Helper wrapper class for `BluetoothConnection` - private class BluetoothConnectionWrapper extends BluetoothConnectionClassic { - private final int id; - - protected EventSink readSink; - - protected EventChannel readChannel; - - private final BluetoothConnectionWrapper self = this; - - public BluetoothConnectionWrapper(int id, BluetoothAdapter adapter) { - super(adapter); - this.id = id; - - readChannel = new EventChannel(messenger, PLUGIN_NAMESPACE + "/read/" + id); - // If canceled by local, disconnects - in other case, by remote, does nothing - // True dispose - StreamHandler readStreamHandler = new StreamHandler() { - @Override - public void onListen(Object o, EventSink eventSink) { - readSink = eventSink; - } - - @Override - public void onCancel(Object o) { - // If canceled by local, disconnects - in other case, by remote, does nothing - self.disconnect(); - - // True dispose - AsyncTask.execute(() -> { - readChannel.setStreamHandler(null); - connections.remove(id); - - Log.d(TAG, "Disconnected (id: " + id + ")"); - }); - } - }; - readChannel.setStreamHandler(readStreamHandler); - } - - @Override - public void onRead(byte[] buffer) { - activity.runOnUiThread(() -> { - if (readSink != null) { - readSink.success(buffer); - } - }); - } - - @Override - public void onDisconnected(boolean byRemote) { - activity.runOnUiThread(() -> { - if (byRemote) { - Log.d(TAG, "onDisconnected by remote (id: " + id + ")"); - if (readSink != null) { - readSink.endOfStream(); - readSink = null; - } - } else { - Log.d(TAG, "onDisconnected by local (id: " + id + ")"); - } - }); - } - } - private class FlutterBluetoothSerialMethodCallHandler implements MethodCallHandler { /// Provides access to the plugin methods @Override @@ -994,6 +929,8 @@ public void onReceive(Context context, Intent intent) { break; } + boolean isLE = call.hasArgument("isLE") && Boolean.TRUE.equals(call.argument("isLE")); + String address; try { address = call.argument("address"); @@ -1005,8 +942,73 @@ public void onReceive(Context context, Intent intent) { break; } + BluetoothConnection connection; + BluetoothConnection[] connection0 = {null}; + int id = ++lastConnectionId; - BluetoothConnection connection = new BluetoothConnectionWrapper(id, bluetoothAdapter); + + EventSink[] readSink = {null}; + + // I think this code is to effect disconnection when the plugin is unloaded or something? + EventChannel readChannel = new EventChannel(messenger, PLUGIN_NAMESPACE + "/read/" + id); + // If canceled by local, disconnects - in other case, by remote, does nothing + // True dispose + StreamHandler readStreamHandler = new StreamHandler() { + @Override + public void onListen(Object o, EventSink eventSink) { + readSink[0] = eventSink; + } + + @Override + public void onCancel(Object o) { + // If canceled by local, disconnects - in other case, by remote, does nothing + connection0[0].disconnect(); + + // True dispose + AsyncTask.execute(() -> { + readChannel.setStreamHandler(null); + connections.remove(id); + + Log.d(TAG, "Disconnected (id: " + id + ")"); + }); + } + }; + readChannel.setStreamHandler(readStreamHandler); //LEAK //THINK I don't know if this should go after the BluetoothConnection is created or not + + BluetoothConnectionBase.OnReadCallback orc = new BluetoothConnectionBase.OnReadCallback() { + @Override + public void onRead(byte[] data) { + activity.runOnUiThread(() -> { + if (readSink[0] != null) { + readSink[0].success(data); + } + }); + } + }; + + BluetoothConnectionBase.OnDisconnectedCallback odc = new BluetoothConnectionBase.OnDisconnectedCallback() { + @Override + public void onDisconnected(boolean byRemote) { + activity.runOnUiThread(() -> { + if (byRemote) { + Log.d(TAG, "onDisconnected by remote (id: " + id + ")"); + if (readSink[0] != null) { + readSink[0].endOfStream(); + readSink[0] = null; + } + } else { + Log.d(TAG, "onDisconnected by local (id: " + id + ")"); + } + }); + } + }; + + if (isLE) { + connection0[0] = new BluetoothConnectionLE(orc, odc); + } else { + connection0[0] = new BluetoothConnectionClassic(orc, odc, bluetoothAdapter); + } + connection = connection0[0]; connections.put(id, connection); Log.d(TAG, "Connecting to " + address + " (id: " + id + ")"); diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java index fc79deba..7837c215 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java @@ -7,13 +7,18 @@ import java.util.UUID; import io.github.edufolly.flutterbluetoothserial.BluetoothConnection; +import io.github.edufolly.flutterbluetoothserial.BluetoothConnectionBase; -public abstract class BluetoothConnectionLE implements BluetoothConnection { +public class BluetoothConnectionLE extends BluetoothConnectionBase { private enum Connected { False, Pending, True } private Connected connected = Connected.False; private SerialSocket socket; + public BluetoothConnectionLE(OnReadCallback onReadCallback, OnDisconnectedCallback onDisconnectedCallback) { + super(onReadCallback, onDisconnectedCallback); + } + @Override public boolean isConnected() { return connected == Connected.True; @@ -63,8 +68,4 @@ public void write(byte[] data) throws IOException { } socket.write(data); } - - public abstract void onRead(byte[] data); - - public abstract void onDisconnected(boolean byRemote); } diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java index 9be04fca..18e117d2 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialListener.java @@ -2,7 +2,7 @@ import java.util.ArrayDeque; -interface SerialListener { +public interface SerialListener { void onSerialConnect (); void onSerialConnectError (Exception e); void onSerialRead (byte[] data); // socket -> service From fa371f45d0150a59c482be6856dee53a04c969f7 Mon Sep 17 00:00:00 2001 From: erhannis Date: Fri, 30 Jun 2023 19:54:57 -0400 Subject: [PATCH 06/13] BROKEN It compiles, now, but A the IDE still cries errors, and B there's a bunch of stuff I haven't implemented --- .../BluetoothConnectionBase.java | 2 +- .../FlutterBluetoothSerialPlugin.java | 4 +- .../le/BluetoothConnectionLE.java | 46 ++- .../flutterbluetoothserial/le/Constants.java | 12 +- .../le/DevicesFragment.java | 297 ------------------ .../le/SerialService.java | 8 +- 6 files changed, 53 insertions(+), 316 deletions(-) delete mode 100644 android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java index adda3202..d58bad64 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/BluetoothConnectionBase.java @@ -1,6 +1,6 @@ package io.github.edufolly.flutterbluetoothserial; -public abstract class BluetoothConnectionBase implements BluetoothConnection { +public abstract class BluetoothConnectionBase implements io.github.edufolly.flutterbluetoothserial.BluetoothConnection { public interface OnReadCallback { public void onRead(byte[] data); } diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java index 2e645b55..4d52505c 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/FlutterBluetoothSerialPlugin.java @@ -71,7 +71,7 @@ public class FlutterBluetoothSerialPlugin implements FlutterPlugin, ActivityAwar // Connections /// Contains all active connections. Maps ID of the connection with plugin data channels. - private final SparseArray connections = new SparseArray<>(2); + private final SparseArray connections = new SparseArray(2); /// Last ID given to any connection, used to avoid duplicate IDs private int lastConnectionId = 0; @@ -1004,7 +1004,7 @@ public void onDisconnected(boolean byRemote) { }; if (isLE) { - connection0[0] = new BluetoothConnectionLE(orc, odc); + connection0[0] = new BluetoothConnectionLE(orc, odc, activeContext); } else { connection0[0] = new BluetoothConnectionClassic(orc, odc, bluetoothAdapter); } diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java index 7837c215..e719ba0f 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/BluetoothConnectionLE.java @@ -2,21 +2,27 @@ import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; +import android.content.Context; import java.io.IOException; +import java.util.ArrayDeque; import java.util.UUID; import io.github.edufolly.flutterbluetoothserial.BluetoothConnection; import io.github.edufolly.flutterbluetoothserial.BluetoothConnectionBase; +import io.github.edufolly.flutterbluetoothserial.BluetoothConnectionBase.OnReadCallback; +import io.github.edufolly.flutterbluetoothserial.BluetoothConnectionBase.OnDisconnectedCallback; public class BluetoothConnectionLE extends BluetoothConnectionBase { - private enum Connected { False, Pending, True } + public enum Connected { False, Pending, True } //DUMMY IDE claiming non-accessible private Connected connected = Connected.False; private SerialSocket socket; + private Context ctx; - public BluetoothConnectionLE(OnReadCallback onReadCallback, OnDisconnectedCallback onDisconnectedCallback) { + public BluetoothConnectionLE(OnReadCallback onReadCallback, OnDisconnectedCallback onDisconnectedCallback, Context appCtx) { super(onReadCallback, onDisconnectedCallback); + this.ctx = appCtx; } @Override @@ -37,15 +43,41 @@ public void connect(String address) throws IOException { try { BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); BluetoothDevice device = bluetoothAdapter.getRemoteDevice(address); - status("connecting..."); + System.out.println("connecting..."); connected = Connected.Pending; - SerialSocket socket = new SerialSocket(getActivity().getApplicationContext(), device); + SerialSocket socket = new SerialSocket(ctx, device); - socket.connect(this); + socket.connect(new io.github.edufolly.flutterbluetoothserial.le.SerialListener() { + @Override + public void onSerialConnect() { + throw new RuntimeException("//DUMMY"); + } + + @Override + public void onSerialConnectError(Exception e) { + throw new RuntimeException("//DUMMY"); + } + + @Override + public void onSerialRead(byte[] data) { + throw new RuntimeException("//DUMMY"); + } + + @Override + public void onSerialRead(ArrayDeque datas) { + throw new RuntimeException("//DUMMY"); + } + + @Override + public void onSerialIoError(Exception e) { + throw new RuntimeException("//DUMMY"); + } + }); this.socket = socket; connected = Connected.True; } catch (Exception e) { - onSerialConnectError(e); + System.err.println("connection failed: " + e.getMessage()); + disconnect(); } } @@ -53,7 +85,7 @@ public void connect(String address) throws IOException { public void disconnect() { if (isConnected()) { connected = Connected.False; // ignore data,errors while disconnecting - cancelNotification(); + //cancelNotification(); //DUMMY if (socket != null) { socket.disconnect(); socket = null; diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java index 6156cba3..25afd273 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/Constants.java @@ -1,14 +1,16 @@ package io.github.edufolly.flutterbluetoothserial.le; -class Constants { +import io.github.edufolly.flutterbluetoothserial.BuildConfig; + +public class Constants { // values have to be globally unique - static final String INTENT_ACTION_DISCONNECT = BuildConfig.APPLICATION_ID + ".Disconnect"; - static final String NOTIFICATION_CHANNEL = BuildConfig.APPLICATION_ID + ".Channel"; - static final String INTENT_CLASS_MAIN_ACTIVITY = BuildConfig.APPLICATION_ID + ".MainActivity"; + public static final String INTENT_ACTION_DISCONNECT = BuildConfig.LIBRARY_PACKAGE_NAME + ".Disconnect"; + public static final String NOTIFICATION_CHANNEL = BuildConfig.LIBRARY_PACKAGE_NAME + ".Channel"; + public static final String INTENT_CLASS_MAIN_ACTIVITY = BuildConfig.LIBRARY_PACKAGE_NAME + ".MainActivity"; // values have to be unique within each app - static final int NOTIFY_MANAGER_START_FOREGROUND_SERVICE = 1001; + public static final int NOTIFY_MANAGER_START_FOREGROUND_SERVICE = 1001; private Constants() {} } diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java deleted file mode 100644 index f1ec2a88..00000000 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/DevicesFragment.java +++ /dev/null @@ -1,297 +0,0 @@ -package io.github.edufolly.flutterbluetoothserial.le; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.app.AlertDialog; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.pm.PackageManager; -import android.location.LocationManager; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.ListView; -import android.widget.TextView; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.ListFragment; - -import java.util.ArrayList; -import java.util.Collections; - -/** - * show list of BLE devices - */ -public class DevicesFragment extends ListFragment { - - private enum ScanState { NONE, LE_SCAN, DISCOVERY, DISCOVERY_FINISHED } - private ScanState scanState = ScanState.NONE; - private static final long LE_SCAN_PERIOD = 10000; // similar to bluetoothAdapter.startDiscovery - private final Handler leScanStopHandler = new Handler(); - private final BluetoothAdapter.LeScanCallback leScanCallback; - private final Runnable leScanStopCallback; - private final BroadcastReceiver discoveryBroadcastReceiver; - private final IntentFilter discoveryIntentFilter; - - private Menu menu; - private BluetoothAdapter bluetoothAdapter; - private final ArrayList listItems = new ArrayList<>(); - private ArrayAdapter listAdapter; - ActivityResultLauncher requestBluetoothPermissionLauncherForStartScan; - ActivityResultLauncher requestLocationPermissionLauncherForStartScan; - - public DevicesFragment() { - leScanCallback = (device, rssi, scanRecord) -> { - if(device != null && getActivity() != null) { - getActivity().runOnUiThread(() -> { updateScan(device); }); - } - }; - discoveryBroadcastReceiver = new BroadcastReceiver() { - @SuppressLint("MissingPermission") - @Override - public void onReceive(Context context, Intent intent) { - if(BluetoothDevice.ACTION_FOUND.equals(intent.getAction())) { - BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); - if(device.getType() != BluetoothDevice.DEVICE_TYPE_CLASSIC && getActivity() != null) { - getActivity().runOnUiThread(() -> updateScan(device)); - } - } - if(BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals(intent.getAction())) { - scanState = ScanState.DISCOVERY_FINISHED; // don't cancel again - stopScan(); - } - } - }; - discoveryIntentFilter = new IntentFilter(); - discoveryIntentFilter.addAction(BluetoothDevice.ACTION_FOUND); - discoveryIntentFilter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); - leScanStopCallback = this::stopScan; // w/o explicit Runnable, a new lambda would be created on each postDelayed, which would not be found again by removeCallbacks - requestBluetoothPermissionLauncherForStartScan = registerForActivityResult( - new ActivityResultContracts.RequestMultiplePermissions(), - granted -> BluetoothUtil.onPermissionsResult(this, granted, this::startScan)); - requestLocationPermissionLauncherForStartScan = registerForActivityResult( - new ActivityResultContracts.RequestPermission(), - granted -> { - if (granted) { - new Handler(Looper.getMainLooper()).postDelayed(this::startScan, 1); // run after onResume to avoid wrong empty-text - } else { - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle("location_permission_title"); //DUMMY - builder.setMessage("location_permission_denied"); //DUMMY - builder.setPositiveButton(android.R.string.ok, null); - builder.show(); - } - }); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - if(getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) - bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); - listAdapter = new ArrayAdapter(getActivity(), 0, listItems) { - @NonNull - @Override - public View getView(int position, View view, @NonNull ViewGroup parent) { - BluetoothUtil.Device device = listItems.get(position); - if (view == null) - view = getActivity().getLayoutInflater().inflate(R.layout.device_list_item, parent, false); - TextView text1 = view.findViewById(R.id.text1); - TextView text2 = view.findViewById(R.id.text2); - String deviceName = device.getName(); - if(deviceName == null || deviceName.isEmpty()) - deviceName = ""; - text1.setText(deviceName); - text2.setText(device.getDevice().getAddress()); - return view; - } - }; - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - setListAdapter(null); - View header = getActivity().getLayoutInflater().inflate(R.layout.device_list_header, null, false); - getListView().addHeaderView(header, null, false); - setEmptyText("initializing..."); - ((TextView) getListView().getEmptyView()).setTextSize(18); - setListAdapter(listAdapter); - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_devices, menu); - this.menu = menu; - if (bluetoothAdapter == null) { - menu.findItem(R.id.bt_settings).setEnabled(false); - menu.findItem(R.id.ble_scan).setEnabled(false); - } else if(!bluetoothAdapter.isEnabled()) { - menu.findItem(R.id.ble_scan).setEnabled(false); - } - } - - @Override - public void onResume() { - super.onResume(); - getActivity().registerReceiver(discoveryBroadcastReceiver, discoveryIntentFilter); - if(bluetoothAdapter == null) { - setEmptyText(""); - } else if(!bluetoothAdapter.isEnabled()) { - setEmptyText(""); - if (menu != null) { - listItems.clear(); - listAdapter.notifyDataSetChanged(); - menu.findItem(R.id.ble_scan).setEnabled(false); - } - } else { - setEmptyText(""); - if (menu != null) - menu.findItem(R.id.ble_scan).setEnabled(true); - } - } - - @Override - public void onPause() { - super.onPause(); - stopScan(); - getActivity().unregisterReceiver(discoveryBroadcastReceiver); - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - menu = null; - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int id = item.getItemId(); - if (id == R.id.ble_scan) { - startScan(); - return true; - } else if (id == R.id.ble_scan_stop) { - stopScan(); - return true; - } else if (id == R.id.bt_settings) { - Intent intent = new Intent(); - intent.setAction(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS); - startActivity(intent); - return true; - } else { - return super.onOptionsItemSelected(item); - } - } - - private void startScan() { - if(scanState != ScanState.NONE) - return; - ScanState nextScanState = ScanState.LE_SCAN; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if(!BluetoothUtil.hasPermissions(this, requestBluetoothPermissionLauncherForStartScan)) - return; - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (getActivity().checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { - scanState = ScanState.NONE; - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setTitle(R.string.location_permission_title); - builder.setMessage(R.string.location_permission_grant); - builder.setPositiveButton(android.R.string.ok, - (dialog, which) -> requestLocationPermissionLauncherForStartScan.launch(Manifest.permission.ACCESS_FINE_LOCATION)); - builder.show(); - return; - } - LocationManager locationManager = (LocationManager) getActivity().getSystemService(Context.LOCATION_SERVICE); - boolean locationEnabled = false; - try { - locationEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER); - } catch(Exception ignored) {} - try { - locationEnabled |= locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER); - } catch(Exception ignored) {} - if(!locationEnabled) - scanState = ScanState.DISCOVERY; - // Starting with Android 6.0 a bluetooth scan requires ACCESS_COARSE_LOCATION permission, but that's not all! - // LESCAN also needs enabled 'location services', whereas DISCOVERY works without. - // Most users think of GPS as 'location service', but it includes more, as we see here. - // Instead of asking the user to enable something they consider unrelated, - // we fall back to the older API that scans for bluetooth classic _and_ LE - // sometimes the older API returns less results or slower - } - scanState = nextScanState; - listItems.clear(); - listAdapter.notifyDataSetChanged(); - setEmptyText(""); - menu.findItem(R.id.ble_scan).setVisible(false); - menu.findItem(R.id.ble_scan_stop).setVisible(true); - if(scanState == ScanState.LE_SCAN) { - leScanStopHandler.postDelayed(leScanStopCallback, LE_SCAN_PERIOD); - new Thread(() -> bluetoothAdapter.startLeScan(null, leScanCallback), "startLeScan") - .start(); // start async to prevent blocking UI, because startLeScan sometimes take some seconds - } else { - bluetoothAdapter.startDiscovery(); - } - } - - @SuppressLint("MissingPermission") - private void updateScan(BluetoothDevice device) { - if(scanState == ScanState.NONE) - return; - BluetoothUtil.Device device2 = new BluetoothUtil.Device(device); // slow getName() only once - int pos = Collections.binarySearch(listItems, device2); - if (pos < 0) { - listItems.add(-pos - 1, device2); - listAdapter.notifyDataSetChanged(); - } - } - - @SuppressLint("MissingPermission") - private void stopScan() { - if(scanState == ScanState.NONE) - return; - setEmptyText(""); - if(menu != null) { - menu.findItem(R.id.ble_scan).setVisible(true); - menu.findItem(R.id.ble_scan_stop).setVisible(false); - } - switch(scanState) { - case LE_SCAN: - leScanStopHandler.removeCallbacks(leScanStopCallback); - bluetoothAdapter.stopLeScan(leScanCallback); - break; - case DISCOVERY: - bluetoothAdapter.cancelDiscovery(); - break; - default: - // already canceled - } - scanState = ScanState.NONE; - - } - - @Override - public void onListItemClick(@NonNull ListView l, @NonNull View v, int position, long id) { - stopScan(); - BluetoothUtil.Device device = listItems.get(position-1); - Bundle args = new Bundle(); - args.putString("device", device.getDevice().getAddress()); - Fragment fragment = new TerminalFragment(); - fragment.setArguments(args); - getFragmentManager().beginTransaction().replace(R.id.fragment, fragment, "terminal").addToBackStack(null).commit(); - } -} diff --git a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java index 9eb58b97..79a0b9a2 100644 --- a/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java +++ b/android/src/main/java/io/github/edufolly/flutterbluetoothserial/le/SerialService.java @@ -156,13 +156,13 @@ private void createNotification() { PendingIntent disconnectPendingIntent = PendingIntent.getBroadcast(this, 1, disconnectIntent, flags); PendingIntent restartPendingIntent = PendingIntent.getActivity(this, 1, restartIntent, flags); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, Constants.NOTIFICATION_CHANNEL) - .setSmallIcon(R.drawable.ic_notification) - .setColor(getResources().getColor(R.color.colorPrimary)) - .setContentTitle(getResources().getString(R.string.app_name)) + //.setSmallIcon(android.R.drawable.ic_notification) + .setColor(0xffd84315) + .setContentTitle("//DUMMY appname") .setContentText(socket != null ? "Connected to "+socket.getName() : "Background Service") .setContentIntent(restartPendingIntent) .setOngoing(true) - .addAction(new NotificationCompat.Action(R.drawable.ic_clear_white_24dp, "Disconnect", disconnectPendingIntent)); + .addAction(new NotificationCompat.Action(android.R.drawable.ic_delete, "Disconnect", disconnectPendingIntent)); // @drawable/ic_notification created with Android Studio -> New -> Image Asset using @color/colorPrimaryDark as background color // Android < API 21 does not support vectorDrawables in notifications, so both drawables used here, are created as .png instead of .xml Notification notification = builder.build(); From 8033e3d8c971be8a2fd531956a18cad76b3b35a4 Mon Sep 17 00:00:00 2001 From: erhannis Date: Fri, 30 Jun 2023 21:42:49 -0400 Subject: [PATCH 07/13] Updated gradle. Also, I think switching my java available on PATH from 8 to 11 fixed the IDE errors? --- android/build.gradle | 1 + example/android/app/build.gradle | 7 ++++--- example/android/app/src/main/AndroidManifest.xml | 3 +-- example/android/build.gradle | 2 +- example/android/gradle.properties | 3 +++ example/android/gradle/wrapper/gradle-wrapper.properties | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 0b58a5e1..185d1c55 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -34,6 +34,7 @@ android { implementation 'androidx.appcompat:appcompat:1.3.0' } buildToolsVersion '30.0.3' + namespace 'io.github.edufolly.flutterbluetoothserial' } dependencies { diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index e39d192e..5064626d 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -16,9 +16,6 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { compileSdkVersion 31 - lintOptions { - disable 'InvalidPackage' - } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "io.github.edufolly.flutterbluetoothserialexample" @@ -40,6 +37,10 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + namespace 'io.github.edufolly.flutterbluetoothserialexample' + lint { + disable 'InvalidPackage' + } } flutter { diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index e46d2705..bfd4f1b0 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - +