From 7aeaf41555deb263bb8c260e5186e4be8497c9cd Mon Sep 17 00:00:00 2001 From: Ivan Date: Fri, 10 Jul 2026 15:18:03 -0500 Subject: [PATCH] feat(android): update BLE permissions handling --- android/app/src/main/java/org/able/BLE.java | 147 ++++++++++++++++---- tests/frontend/Interface.test.js | 7 +- 2 files changed, 127 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/java/org/able/BLE.java b/android/app/src/main/java/org/able/BLE.java index 9dabcfb4..05d042b0 100644 --- a/android/app/src/main/java/org/able/BLE.java +++ b/android/app/src/main/java/org/able/BLE.java @@ -1,5 +1,7 @@ package org.able; +import android.Manifest; +import android.annotation.SuppressLint; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; @@ -19,14 +21,22 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; +import android.os.Build; import android.util.Log; +import androidx.core.content.ContextCompat; + import java.util.List; /** * Android BLE helper used by MeshChatX's Chaquopy able package. * Context is injected from MainActivity instead of Kivy PythonActivity. + * + * Runtime BLUETOOTH_SCAN / BLUETOOTH_CONNECT checks gate every privileged call. + * SuppressLint covers lint's MissingPermission analysis, which does not always + * follow those helpers across API-level branches. */ +@SuppressLint("MissingPermission") public class BLE { private static final String TAG = "BLE-meshchatx"; @@ -77,13 +87,38 @@ public class BLE { ); } + private boolean hasConnectPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return true; + } + return ContextCompat.checkSelfPermission(mContext, Manifest.permission.BLUETOOTH_CONNECT) + == PackageManager.PERMISSION_GRANTED; + } + + private boolean hasScanPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return true; + } + return ContextCompat.checkSelfPermission(mContext, Manifest.permission.BLUETOOTH_SCAN) + == PackageManager.PERMISSION_GRANTED; + } + public BluetoothAdapter getAdapter(int enableBtCode) { if (mBluetoothAdapter == null) { showError("Device does not support Bluetooth Low Energy."); return null; } - if (!mBluetoothAdapter.isEnabled()) { - showError("BLE adapter is not enabled"); + if (!hasConnectPermission()) { + showError("Bluetooth connect permission is not granted."); + return null; + } + try { + if (!mBluetoothAdapter.isEnabled()) { + showError("BLE adapter is not enabled"); + return null; + } + } catch (SecurityException e) { + showError("Bluetooth connect permission is not granted."); return null; } return mBluetoothAdapter; @@ -99,30 +134,49 @@ public class BLE { public void startScan(int enableBtCode, List filters, ScanSettings settings) { Log.d(TAG, "startScan"); + if (!hasScanPermission()) { + showError("Bluetooth scan permission is not granted."); + mPython.on_scan_started(false); + return; + } BluetoothAdapter adapter = getAdapter(enableBtCode); if (adapter == null) { return; } - if (mBluetoothLeScanner == null) { - mBluetoothLeScanner = adapter.getBluetoothLeScanner(); - } - if (mBluetoothLeScanner != null) { - mScanning = false; - mBluetoothLeScanner.startScan(filters, settings, mScanCallback); - } else { - showError("Could not get BLE Scanner object."); + try { + if (mBluetoothLeScanner == null) { + mBluetoothLeScanner = adapter.getBluetoothLeScanner(); + } + if (mBluetoothLeScanner != null) { + mScanning = false; + mBluetoothLeScanner.startScan(filters, settings, mScanCallback); + } else { + showError("Could not get BLE Scanner object."); + mPython.on_scan_started(false); + } + } catch (SecurityException e) { + showError("Bluetooth scan permission is not granted."); mPython.on_scan_started(false); } } public void stopScan() { - if (mBluetoothLeScanner != null) { + if (mBluetoothLeScanner == null) { + return; + } + if (!hasScanPermission()) { + showError("Bluetooth scan permission is not granted."); + return; + } + try { Log.d(TAG, "stopScan"); mBluetoothLeScanner.stopScan(mScanCallback); if (mScanning) { mScanning = false; mPython.on_scan_completed(); } + } catch (SecurityException e) { + showError("Bluetooth scan permission is not granted."); } } @@ -155,13 +209,21 @@ public class BLE { public void connectGatt(BluetoothDevice device, boolean autoConnect) { Log.d(TAG, "connectGatt"); + if (!hasConnectPermission()) { + showError("Bluetooth connect permission is not granted."); + return; + } if (mBluetoothGatt == null) { - mBluetoothGatt = device.connectGatt( - mContext, - autoConnect, - mGattCallback, - BluetoothDevice.TRANSPORT_LE - ); + try { + mBluetoothGatt = device.connectGatt( + mContext, + autoConnect, + mGattCallback, + BluetoothDevice.TRANSPORT_LE + ); + } catch (SecurityException e) { + showError("Bluetooth connect permission is not granted."); + } } else { Log.d(TAG, "BluetoothGatt exists, call closeGatt() before reconnecting"); } @@ -169,9 +231,18 @@ public class BLE { public void closeGatt() { Log.d(TAG, "closeGatt"); - if (mBluetoothGatt != null) { + if (mBluetoothGatt == null) { + return; + } + if (!hasConnectPermission()) { + showError("Bluetooth connect permission is not granted."); + return; + } + try { mBluetoothGatt.close(); mBluetoothGatt = null; + } catch (SecurityException e) { + showError("Bluetooth connect permission is not granted."); } } @@ -273,20 +344,44 @@ public class BLE { byte[] data, int writeType ) { - if (characteristic.setValue(data)) { - if (writeType != 0) { - characteristic.setWriteType(writeType); - } - return mBluetoothGatt != null && mBluetoothGatt.writeCharacteristic(characteristic); + if (!hasConnectPermission() || mBluetoothGatt == null) { + return false; + } + if (!characteristic.setValue(data)) { + return false; + } + if (writeType != 0) { + characteristic.setWriteType(writeType); + } + try { + return mBluetoothGatt.writeCharacteristic(characteristic); + } catch (SecurityException e) { + showError("Bluetooth connect permission is not granted."); + return false; } - return false; } public boolean readCharacteristic(BluetoothGattCharacteristic characteristic) { - return mBluetoothGatt != null && mBluetoothGatt.readCharacteristic(characteristic); + if (!hasConnectPermission() || mBluetoothGatt == null) { + return false; + } + try { + return mBluetoothGatt.readCharacteristic(characteristic); + } catch (SecurityException e) { + showError("Bluetooth connect permission is not granted."); + return false; + } } public boolean readRemoteRssi() { - return mBluetoothGatt != null && mBluetoothGatt.readRemoteRssi(); + if (!hasConnectPermission() || mBluetoothGatt == null) { + return false; + } + try { + return mBluetoothGatt.readRemoteRssi(); + } catch (SecurityException e) { + showError("Bluetooth connect permission is not granted."); + return false; + } } } diff --git a/tests/frontend/Interface.test.js b/tests/frontend/Interface.test.js index 830409b1..79a9884e 100644 --- a/tests/frontend/Interface.test.js +++ b/tests/frontend/Interface.test.js @@ -93,10 +93,15 @@ describe("Interface.vue", () => { it("action buttons and dropdown have shrink-0 to prevent squashing", () => { const wrapper = mountInterface(); - const actionsCol = wrapper.find(".absolute.top-2.right-2.z-20.flex.flex-row.items-center.gap-1.sm\\:static"); + const actionsCol = wrapper.find( + ".flex.flex-row.items-center.gap-1.sm\\:relative.sm\\:z-auto.sm\\:flex.sm\\:flex-row.sm\\:gap-2.sm\\:items-center.sm\\:shrink-0.sm\\:justify-end" + ); + expect(actionsCol.exists()).toBe(true); expect(actionsCol.classes()).toContain("sm:shrink-0"); const btn = wrapper.find('button[title="interface.disable"]'); expect(btn.classes()).toContain("shrink-0"); + const dropdown = wrapper.find(".relative.z-50.shrink-0"); + expect(dropdown.exists()).toBe(true); }); it("detail-value has break-all for long addresses", () => {