From 5d8d8165fc3537fca5f8560a5034014e92903a7c Mon Sep 17 00:00:00 2001 From: oliexdev Date: Wed, 20 Aug 2025 11:23:11 +0200 Subject: [PATCH] Refactors Bluetooth permission handling and connection logic across several parts of the application --- .../bluetooth/scales/ModernScaleAdapter.kt | 98 +++++--- .../scalesJava/BluetoothBroadcastScale.java | 31 +-- .../scalesJava/BluetoothCommunication.java | 62 ++++-- .../ui/screen/bluetooth/BluetoothViewModel.kt | 65 ++---- .../ui/screen/overview/OverviewScreen.kt | 210 ++++++++++-------- .../ui/screen/settings/BluetoothScreen.kt | 99 ++++++--- .../app/src/main/res/values-de/strings.xml | 1 - .../app/src/main/res/values/strings.xml | 1 - 8 files changed, 348 insertions(+), 219 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt index 258928dd..3fc0f07c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt @@ -78,10 +78,6 @@ class ModernScaleAdapter( ) : ScaleCommunicator { private val TAG = "ModernScaleAdapter" - private companion object { - const val TAG = "ModernScaleAdapter" - } - private val adapterScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val mainHandler = Handler(Looper.getMainLooper()) private lateinit var central: BluetoothCentralManager // Initialisiert in init @@ -185,35 +181,53 @@ class ModernScaleAdapter( } private fun hasRequiredBluetoothPermissions(): Boolean { - val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) - } else { - listOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION) - } - return requiredPermissions.all { - ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED - } + val required = listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + ) + return required.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } + // Call this after runtime grant or lazily before first connect + private fun ensureCentralReady(): Boolean { + if (!::central.isInitialized) { + val hasScan = ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_SCAN + ) == PackageManager.PERMISSION_GRANTED + val hasConnect = ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + if (!(hasScan || hasConnect)) return false // at least one check; we hard-fail later if CONNECT is missing + central = BluetoothCentralManager(context, bluetoothCentralManagerCallback, mainHandler) + LogManager.d(TAG, "BluetoothCentralManager initialized") + } + return true + } + + override fun connect(address: String, scaleUser: ScaleUser?) { adapterScope.launch { - if (!::central.isInitialized) { - LogManager.e(TAG, "BluetoothCentralManager nicht initialisiert, wahrscheinlich aufgrund fehlender Berechtigungen.") - _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Bluetooth nicht initialisiert (Berechtigungen?)")) + // Ensure central exists (may be created after runtime grant) + if (!ensureCentralReady()) { + _eventsFlow.tryEmit( + BluetoothEvent.ConnectionFailed(address, "Bluetooth permissions missing (SCAN/CONNECT)") + ) return@launch } + // Ignore duplicate connects if (_isConnecting.value || (_isConnected.value && targetAddress == address)) { - LogManager.d(TAG, "Verbindungsanfrage für $address ignoriert: Bereits verbunden oder Verbindungsaufbau läuft.") + LogManager.d(TAG, "connect($address) ignored: already connecting/connected") if (_isConnected.value && targetAddress == address) { - val deviceName = currentPeripheral?.name ?: "Unbekanntes Gerät" + val deviceName = currentPeripheral?.name ?: "Unknown" _eventsFlow.tryEmit(BluetoothEvent.Connected(deviceName, address)) } return@launch } + // Switch target: tear down old connection attempt if ((_isConnected.value || _isConnecting.value) && targetAddress != address) { - LogManager.d(TAG, "Bestehende Verbindung/Versuch zu $targetAddress wird für neue Verbindung zu $address getrennt.") + LogManager.d(TAG, "Switching from $targetAddress to $address") disconnectLogic() } @@ -222,26 +236,54 @@ class ModernScaleAdapter( targetAddress = address currentScaleUser = scaleUser - LogManager.i(TAG, "Verbindungsversuch zu $address mit Benutzer: ${scaleUser?.id}") + LogManager.i(TAG, "Connecting to $address (user=${scaleUser?.id})") - // Stoppe vorherige Scans, falls vorhanden - central.stopScan() + // Stop any previous scans + runCatching { central.stopScan() } + + val hasScan = ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_SCAN + ) == PackageManager.PERMISSION_GRANTED + + val hasConnect = ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + + if (!hasConnect) { + // Without CONNECT we can't perform GATT ops reliably + LogManager.e(TAG, "Missing BLUETOOTH_CONNECT") + _eventsFlow.tryEmit( + BluetoothEvent.ConnectionFailed(address, "Missing BLUETOOTH_CONNECT permission") + ) + _isConnecting.value = false + targetAddress = null + return@launch + } try { - // Versuche, direkt ein Peripheral-Objekt zu bekommen, falls die Adresse bekannt ist. - //Blessed erlaubt auch das Scannen nach Adresse, was oft robuster ist. - //central.getPeripheral(address) ist eine Option, aber scanForPeripheralsWithAddresses ist oft besser. - central.scanForPeripheralsWithAddresses(arrayOf(address)) - LogManager.d(TAG, "Scan gestartet für Adresse: $address") + if (hasScan) { + // Preferred path on 12+: short pre-scan by address + central.scanForPeripheralsWithAddresses(arrayOf(address)) + LogManager.d(TAG, "Pre-scan started for $address") + // onDiscoveredPeripheral will stop scan and connect + } else { + // Fallback: connect without pre-scan (may be less reliable on some OEMs) + LogManager.w(TAG, "BLUETOOTH_SCAN not granted → connecting without pre-scan") + val p = central.getPeripheral(address) + central.connectPeripheral(p, peripheralCallback) + } } catch (e: Exception) { - LogManager.e(TAG, "Fehler beim Starten des Scans für $address", e) - _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Scan konnte nicht gestartet werden: ${e.message}")) + LogManager.e(TAG, "Failed to start connect/scan for $address", e) + _eventsFlow.tryEmit( + BluetoothEvent.ConnectionFailed(address, "Failed to start scan/connect: ${e.message}") + ) _isConnecting.value = false targetAddress = null } } } + private val peripheralCallback: BluetoothPeripheralCallback = object : BluetoothPeripheralCallback() { override fun onServicesDiscovered(peripheral: BluetoothPeripheral) { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothBroadcastScale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothBroadcastScale.java index 5d56afb2..cd2c20dd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothBroadcastScale.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothBroadcastScale.java @@ -17,14 +17,11 @@ package com.health.openscale.core.bluetooth.scalesJava; -import static android.content.Context.LOCATION_SERVICE; - import android.Manifest; import android.bluetooth.le.ScanRecord; import android.bluetooth.le.ScanResult; import android.content.Context; import android.content.pm.PackageManager; -import android.location.LocationManager; import android.os.Handler; import android.os.Looper; import android.util.SparseArray; @@ -136,19 +133,25 @@ public class BluetoothBroadcastScale extends BluetoothCommunication { @Override public void connect(String macAddress) { + // Android 12+ (minSdk=31): scanning requires BLUETOOTH_SCAN, no location needed. + boolean canScan = ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED; - LocationManager locationManager = (LocationManager)context.getSystemService(LOCATION_SERVICE); - - if ((ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) || - (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED ) && - (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || - (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) - ) { - LogManager.d(TAG, "Do LE scan before connecting to device"); - central.scanForPeripheralsWithAddresses(new String[]{macAddress}); + if (!canScan) { + LogManager.e(TAG, "Missing BLUETOOTH_SCAN → cannot start LE scan for broadcast data.", null); + setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR); + // choose a better string if you have one, this is just a visible hint: + sendMessage(com.health.openscale.R.string.info_bluetooth_connection_error_scale_offline, 0); + return; } - else { - LogManager.e(TAG,"No location permission, can't do anything", null); + + try { + LogManager.d(TAG, "Starting LE scan for broadcast scale (no location required)"); + // We scan by address; when advertising is seen, onDiscoveredPeripheral parses manufacturer data. + central.scanForPeripheralsWithAddresses(new String[]{ macAddress }); + } catch (Exception e) { + LogManager.e(TAG, "Failed to start LE scan: " + e.getMessage(), e); + setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR); } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java index 3005361a..6fc4a7f4 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java @@ -18,14 +18,12 @@ package com.health.openscale.core.bluetooth.scalesJava; import static android.bluetooth.BluetoothGatt.GATT_SUCCESS; -import static android.content.Context.LOCATION_SERVICE; import android.Manifest; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.le.ScanResult; import android.content.Context; import android.content.pm.PackageManager; -import android.location.LocationManager; import android.os.Handler; import android.os.Looper; import android.os.Message; @@ -88,7 +86,7 @@ public abstract class BluetoothCommunication { public BluetoothCommunication(Context context) { this.context = context; - this.disconnectHandler = new Handler(); + this.disconnectHandler = new Handler(Looper.getMainLooper()); this.stepNr = 0; this.stopped = false; this.central = new BluetoothCentralManager(context, bluetoothCentralCallback, new Handler(Looper.getMainLooper())); @@ -609,28 +607,50 @@ public abstract class BluetoothCommunication { * @param macAddress the Bluetooth address to connect to */ public void connect(String macAddress) { - // Running an LE scan during connect improves connectivity on some phones - // (e.g. Sony Xperia Z5 compact, Android 7.1.1). For some scales (e.g. Medisana BS444) - // it seems to be a requirement that the scale is discovered before connecting to it. - // Otherwise the connection almost never succeeds. - LocationManager locationManager = (LocationManager)context.getSystemService(LOCATION_SERVICE); + // Android 12+ (API 31+): SCAN needed for scanning, CONNECT needed for connect/GATT. + final boolean isSPlus = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S; - if ((ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) || - (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED ) && - (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || - (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) - ) { - LogManager.d("BluetoothCommunication","Do LE scan before connecting to device"); - central.scanForPeripheralsWithAddresses(new String[]{macAddress}); - stopMachineState(); - } - else { - LogManager.d("BluetoothCommunication","No location permission, connecting without LE scan"); - BluetoothPeripheral peripheral = central.getPeripheral(macAddress); - connectToDevice(peripheral); + if (isSPlus) { + boolean canConnect = ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED; + boolean canScan = ContextCompat.checkSelfPermission( + context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED; + + if (!canConnect) { + LogManager.e("BluetoothCommunication", + "API≥31: Missing BLUETOOTH_CONNECT → cannot connect/GATT. Aborting.", null); + setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR); + sendMessage(R.string.info_bluetooth_connection_error_scale_offline, 0); + return; + } + + // Pre-scan improves connect reliability on some devices/scales + if (canScan) { + LogManager.d("BluetoothCommunication", + "API≥31: Do LE scan before connecting (no location needed)"); + central.scanForPeripheralsWithAddresses(new String[]{macAddress}); + stopMachineState(); // wait for onDiscoveredPeripheral → connect + return; + } else { + LogManager.w("BluetoothCommunication", + "API≥31: BLUETOOTH_SCAN not granted → connecting without pre-scan (may be less reliable)", null); + BluetoothPeripheral peripheral = central.getPeripheral(macAddress); + try { + connectToDevice(peripheral); + } catch (SecurityException se) { + LogManager.e("BluetoothCommunication", + "SecurityException during connect (missing CONNECT?): " + se.getMessage(), se); + setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR); + } + return; + } } + + // Defensive fallback (won't run with minSdk=31, but good for clarity) + LogManager.w("BluetoothCommunication","connect() called on API<31 path; no legacy handling active.", null); } + private void connectToDevice(BluetoothPeripheral peripheral) { Handler handler = new Handler(); diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt index 84e29a03..0df6cef9 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt @@ -23,7 +23,6 @@ import android.app.Application import android.bluetooth.BluetoothManager import android.content.Context import android.content.pm.PackageManager -import android.os.Build import android.os.Handler import android.os.Looper import androidx.compose.material3.SnackbarDuration @@ -280,36 +279,6 @@ class BluetoothViewModel( // --- Connection Control --- - /** - * Initiates a connection attempt to the specified Bluetooth device. - * If a scan is active, it will be stopped first. - * Prerequisites like permissions and Bluetooth status are validated. - * - * @param deviceInfo The [ScannedDeviceInfo] of the device to connect to. - */ - @SuppressLint("MissingPermission") // Permissions are checked by validateConnectionPrerequisites. - fun connectToDevice(deviceInfo: ScannedDeviceInfo) { - val deviceDisplayName = deviceInfo.name ?: deviceInfo.address - LogManager.i(TAG, "User requested to connect to device: $deviceDisplayName") - - if (isScanning.value) { - LogManager.d(TAG, "Scan is active, stopping it before initiating connection to $deviceDisplayName.") - requestStopDeviceScan() - // Optional: A small delay could be added here if needed to ensure scan stop completes, - // but usually the managers handle sequential operations gracefully. - // viewModelScope.launch { delay(200) } - } - - if (!validateConnectionPrerequisites(deviceDisplayName, isManualConnect = true)) { - // validateConnectionPrerequisites logs and shows Snackbar/sets error for ConnectionManager. - return - } - - LogManager.d(TAG, "Prerequisites for connecting to $deviceDisplayName met. Delegating to BluetoothConnectionManager.") - bluetoothConnectionManager.connectToDevice(deviceInfo) - } - - /** * Attempts to connect to the saved preferred Bluetooth scale. * Retrieves device info from [userSettingsRepository] and then delegates @@ -511,20 +480,34 @@ class BluetoothViewModel( /** * Checks if the necessary Bluetooth permissions are currently granted. - * Handles different permission sets for Android S (API 31) and above vs. older versions. + * Handles different permission sets for Android S (API 31) and above. * @return `true` if permissions are granted, `false` otherwise. */ private fun checkInitialPermissions(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Android 12 (API 31) and above require BLUETOOTH_SCAN and BLUETOOTH_CONNECT - ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED - } else { - // For older Android versions (below S / API 31) - ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission(application, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + val hasConnect = ContextCompat.checkSelfPermission( + application, Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED + + val hasScan = ContextCompat.checkSelfPermission( + application, Manifest.permission.BLUETOOTH_SCAN + ) == PackageManager.PERMISSION_GRANTED + + when { + !hasConnect && !hasScan -> { + LogManager.w(TAG, "Missing permissions: BLUETOOTH_CONNECT & BLUETOOTH_SCAN → BLE disabled (no connect, no scan).") + } + !hasConnect -> { + LogManager.w(TAG, "Missing permission: BLUETOOTH_CONNECT → cannot perform GATT ops (connect/read/write).") + } + !hasScan -> { + LogManager.w(TAG, "Missing permission: BLUETOOTH_SCAN → cannot scan/pre-scan (no discovery, less reliable connect).") + } + else -> { + LogManager.d(TAG, "All required Bluetooth permissions granted (SCAN & CONNECT).") + } } + + return hasConnect && hasScan } /** diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt index 6afb9a35..d8037426 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/overview/OverviewScreen.kt @@ -17,8 +17,16 @@ */ package com.health.openscale.ui.screen.overview +import android.Manifest +import android.app.Activity +import android.bluetooth.BluetoothAdapter import android.content.Context +import android.content.Intent import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -137,31 +145,47 @@ fun determineBluetoothTopBarAction( currentNavController: NavController, bluetoothViewModel: BluetoothViewModel, sharedViewModel: SharedViewModel, - currentDeviceName: String? + currentDeviceName: String?, + permissionsLauncher: ManagedActivityResultLauncher, Map>, + enableBluetoothLauncher: ManagedActivityResultLauncher ): SharedViewModel.TopBarAction? { - // Logic to determine if a connection or disconnection process is currently active - val btConnectingOrDisconnecting = savedAddr != null && - (connStatusEnum == ConnectionStatus.CONNECTING || connStatusEnum == ConnectionStatus.DISCONNECTING) && - // When connecting, connectedDevice might be null or the address being connected to. - // When disconnecting, connectedDevice should be the address of the device being disconnected. - (connectedDevice == savedAddr || connStatusEnum == ConnectionStatus.CONNECTING || (connStatusEnum == ConnectionStatus.DISCONNECTING && connectedDevice == savedAddr)) val deviceNameForMessage = currentDeviceName ?: context.getString(R.string.fallback_device_name_saved_scale) + // Busy while connecting/disconnecting to the currently saved device + val isBusy = savedAddr != null && + (connStatusEnum == ConnectionStatus.CONNECTING || connStatusEnum == ConnectionStatus.DISCONNECTING) && + (connectedDevice == savedAddr || connStatusEnum == ConnectionStatus.CONNECTING || + (connStatusEnum == ConnectionStatus.DISCONNECTING && connectedDevice == savedAddr)) + + // Helper to request enabling Bluetooth (actual connect is done in onActivityResult when OK) + val requestEnableBluetooth = { + val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + enableBluetoothLauncher.launch(intent) + } + + // Helper to request runtime permissions (actual connect is done in onResult when granted) + val requestBtPermissions = { + permissionsLauncher.launch( + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + ) + ) + } + return when { - // Case 1: Connection or disconnection process is actively running - btConnectingOrDisconnecting -> SharedViewModel.TopBarAction( - icon = Icons.AutoMirrored.Filled.BluetoothSearching, // Icon for "searching" or "working" + // 1) Show non-interactive feedback while a user-initiated operation is ongoing + isBusy -> SharedViewModel.TopBarAction( + icon = Icons.AutoMirrored.Filled.BluetoothSearching, contentDescription = context.getString(R.string.bluetooth_action_connecting_disconnecting_desc), onClick = { - // Typically, the button is not interactive during this time, - // but a Snackbar can confirm the ongoing process. sharedViewModel.showSnackbar( message = context.getString( when (connStatusEnum) { - ConnectionStatus.CONNECTING -> R.string.snackbar_bluetooth_connecting_to + ConnectionStatus.CONNECTING -> R.string.snackbar_bluetooth_connecting_to ConnectionStatus.DISCONNECTING -> R.string.snackbar_bluetooth_disconnecting_from - else -> R.string.snackbar_bluetooth_processing_with // Fallback + else -> R.string.snackbar_bluetooth_processing_with }, deviceNameForMessage ), @@ -170,9 +194,9 @@ fun determineBluetoothTopBarAction( } ) - // Case 2: No Bluetooth scale is saved + // 2) No saved device → navigate to Bluetooth settings savedAddr == null -> SharedViewModel.TopBarAction( - icon = Icons.Default.Bluetooth, // Default Bluetooth icon + icon = Icons.Default.Bluetooth, contentDescription = context.getString(R.string.bluetooth_action_no_scale_saved_desc), onClick = { sharedViewModel.showSnackbar( @@ -183,52 +207,40 @@ fun determineBluetoothTopBarAction( } ) - // Case 3: Successfully connected to the saved scale + // 3) Connected → offer disconnect savedAddr == connectedDevice && connStatusEnum == ConnectionStatus.CONNECTED -> SharedViewModel.TopBarAction( - icon = Icons.Filled.BluetoothConnected, // Icon for "connected" + icon = Icons.Filled.BluetoothConnected, contentDescription = context.getString(R.string.bluetooth_action_disconnect_desc, deviceNameForMessage), onClick = { - // Trigger the action first, then show the Snackbar - bluetoothViewModel.disconnectDevice() // IMPORTANT: Trigger disconnection here! + bluetoothViewModel.disconnectDevice() sharedViewModel.showSnackbar( - message = context.getString(R.string.snackbar_bluetooth_disconnecting_from, deviceNameForMessage), // Adjusted message + message = context.getString(R.string.snackbar_bluetooth_disconnecting_from, deviceNameForMessage), duration = SnackbarDuration.Short ) } ) - // Case 4: Connection error, and an address is saved - connStatusEnum == ConnectionStatus.FAILED && savedAddr != null -> SharedViewModel.TopBarAction( - icon = Icons.Filled.Error, // Error icon - contentDescription = context.getString(R.string.bluetooth_action_retry_connection_desc, deviceNameForMessage), - onClick = { - sharedViewModel.showSnackbar( - message = context.getString(R.string.snackbar_bluetooth_retry_connection, deviceNameForMessage), - duration = SnackbarDuration.Short - ) - bluetoothViewModel.connectToSavedDevice() - } - ) - - // Case 5: Connection error, and NO address is saved - connStatusEnum == ConnectionStatus.FAILED && savedAddr == null -> SharedViewModel.TopBarAction( - icon = Icons.Filled.Error, // Error icon - contentDescription = context.getString(R.string.bluetooth_action_error_check_settings_desc), - onClick = { - sharedViewModel.showSnackbar( - message = context.getString(R.string.snackbar_bluetooth_error_check_settings), - duration = SnackbarDuration.Short - ) - currentNavController.navigate(Routes.BLUETOOTH_SETTINGS) - } - ) - - // Case 6: Saved device exists but is not connected (disconnected, idle, etc.) - // This case also covers if connStatusEnum = DISCONNECTED, IDLE, or NONE. - savedAddr != null && (connStatusEnum == ConnectionStatus.DISCONNECTED || connStatusEnum == ConnectionStatus.IDLE || connStatusEnum == ConnectionStatus.NONE) -> SharedViewModel.TopBarAction( - icon = Icons.Filled.BluetoothDisabled, // Icon for "disconnected" or "ready to connect" + // 4) Disconnected / Idle / None / Failed → guarded connect (request first, connect later via callbacks) + savedAddr != null && ( + connStatusEnum == ConnectionStatus.DISCONNECTED || + connStatusEnum == ConnectionStatus.IDLE || + connStatusEnum == ConnectionStatus.NONE || + connStatusEnum == ConnectionStatus.FAILED + ) -> SharedViewModel.TopBarAction( + icon = Icons.Filled.BluetoothDisabled, contentDescription = context.getString(R.string.bluetooth_action_connect_to_desc, deviceNameForMessage), - onClick = { + onClick = onClick@{ + // a) Ask for permissions if missing (do NOT connect here; wait for onResult) + if (!bluetoothViewModel.permissionsGranted.value) { + requestBtPermissions() + return@onClick + } + // b) Ask to enable BT if off (do NOT connect here; wait for onActivityResult) + if (!bluetoothViewModel.isBluetoothEnabled()) { + requestEnableBluetooth() + return@onClick + } + // c) All good → connect now (explicit user tap; no autoconn) sharedViewModel.showSnackbar( message = context.getString(R.string.snackbar_bluetooth_attempting_connection, deviceNameForMessage), duration = SnackbarDuration.Short @@ -237,43 +249,18 @@ fun determineBluetoothTopBarAction( } ) - // Fallback: If an address is saved, but the state was not specifically covered above, - // offer to connect. Ideally, this shouldn't be hit often if the logic above is complete. - // If no device is saved and there's no error/connection attempt, - // this was already covered by 'savedAddr == null' (leads to settings). - else -> { - if (savedAddr != null) { - // This serves as a generic "Connect" button if a rare state occurs - SharedViewModel.TopBarAction( - icon = Icons.Filled.BluetoothDisabled, - contentDescription = context.getString(R.string.bluetooth_action_connect_to_desc, deviceNameForMessage), - onClick = { - sharedViewModel.showSnackbar( - message = context.getString(R.string.snackbar_bluetooth_attempting_connection, deviceNameForMessage), - duration = SnackbarDuration.Short - ) - bluetoothViewModel.connectToSavedDevice() - } - ) - } else { - // If really no other condition applies and no device is saved, - // and the above cases haven't been met, "Go to settings" is a safe default. - // This will likely only be hit if connStatusEnum has an unexpected value - // and savedAddr is null, but that should already be covered by "Case 2". - // For safety, nonetheless: - SharedViewModel.TopBarAction( - icon = Icons.Default.Bluetooth, - contentDescription = context.getString(R.string.bluetooth_action_check_settings_desc), - onClick = { - sharedViewModel.showSnackbar( - message = context.getString(R.string.snackbar_bluetooth_check_settings), - duration = SnackbarDuration.Short - ) - currentNavController.navigate(Routes.BLUETOOTH_SETTINGS) - } + // 5) Fallback + else -> SharedViewModel.TopBarAction( + icon = Icons.Default.Bluetooth, + contentDescription = context.getString(R.string.bluetooth_action_check_settings_desc), + onClick = { + sharedViewModel.showSnackbar( + message = context.getString(R.string.snackbar_bluetooth_check_settings), + duration = SnackbarDuration.Short ) + currentNavController.navigate(Routes.BLUETOOTH_SETTINGS) } - } + ) } } @@ -330,6 +317,51 @@ fun OverviewScreen( val connectedDeviceAddr by bluetoothViewModel.connectedDeviceAddress.collectAsState() val savedDeviceNameString by bluetoothViewModel.savedScaleName.collectAsState() + val enableBluetoothLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + bluetoothViewModel.refreshPermissionsStatus() + if (result.resultCode == Activity.RESULT_OK) { + if (bluetoothViewModel.permissionsGranted.value) { + // Permissions and BT are fine → safe to connect now + bluetoothViewModel.connectToSavedDevice() + } else { + scope.launch { + sharedViewModel.showSnackbar( + message = context.getString(R.string.bluetooth_enabled_permissions_missing), + duration = SnackbarDuration.Long + ) + } + } + } else { + scope.launch { + sharedViewModel.showSnackbar( + message = context.getString(R.string.bt_snackbar_bluetooth_disabled_to_connect_default), + duration = SnackbarDuration.Long + ) + } + } + } + + val permissionsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + bluetoothViewModel.refreshPermissionsStatus() + val allGranted = result.values.all { it } + if (allGranted) { + if (bluetoothViewModel.isBluetoothEnabled()) { + bluetoothViewModel.connectToSavedDevice() + } else { + val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + enableBluetoothLauncher.launch(intent) + } + } else { + sharedViewModel.showSnackbar(R.string.bt_snackbar_permissions_required_to_connect_default) + } + } + + + // Determine the Bluetooth action for the top bar val bluetoothTopBarAction = determineBluetoothTopBarAction( context = context, @@ -339,7 +371,9 @@ fun OverviewScreen( currentNavController = navController, bluetoothViewModel = bluetoothViewModel, sharedViewModel = sharedViewModel, - currentDeviceName = savedDeviceNameString + currentDeviceName = savedDeviceNameString, + permissionsLauncher = permissionsLauncher, + enableBluetoothLauncher = enableBluetoothLauncher ) // LaunchedEffect to configure the top bar based on the current state diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt index cbd005aa..7ac77b21 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/settings/BluetoothScreen.kt @@ -56,9 +56,13 @@ import androidx.compose.material3.OutlinedCard import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -94,31 +98,15 @@ fun BluetoothScreen( val scanError by bluetoothViewModel.scanError.collectAsState() val connectionError by bluetoothViewModel.connectionError.collectAsState() val hasPermissions by bluetoothViewModel.permissionsGranted.collectAsState() + var pendingScan by remember { mutableStateOf(false) } val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState() val savedDeviceName by bluetoothViewModel.savedScaleName.collectAsState() - val permissionsLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestMultiplePermissions() - ) { permissionsMap -> - bluetoothViewModel.refreshPermissionsStatus() - val allGranted = permissionsMap.values.all { it } - if (allGranted) { - if (!bluetoothViewModel.isBluetoothEnabled()) { - scope.launch { - sharedViewModel.showSnackbar( - message = context.getString(R.string.bluetooth_enable_for_scan), - duration = SnackbarDuration.Short - ) - } - } - } else { - scope.launch { - sharedViewModel.showSnackbar( - message = context.getString(R.string.bluetooth_permissions_required_for_scan), - duration = SnackbarDuration.Long - ) - } + DisposableEffect(Unit) { + onDispose { + pendingScan = false + bluetoothViewModel.requestStopDeviceScan() } } @@ -127,7 +115,19 @@ fun BluetoothScreen( ) { result -> bluetoothViewModel.refreshPermissionsStatus() if (result.resultCode == Activity.RESULT_OK) { - if (!bluetoothViewModel.permissionsGranted.value) { + if (bluetoothViewModel.permissionsGranted.value) { + if (pendingScan) { + bluetoothViewModel.clearAllErrors() + if (!bluetoothViewModel.isScanning.value) { + bluetoothViewModel.requestStartDeviceScan() + } + try { + pendingScan = false + } catch (e: Exception) { + TODO("Not yet implemented") + } + } + } else { scope.launch { sharedViewModel.showSnackbar( message = context.getString(R.string.bluetooth_enabled_permissions_missing), @@ -145,6 +145,36 @@ fun BluetoothScreen( } } + val permissionsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissionsMap -> + bluetoothViewModel.refreshPermissionsStatus() + val allGranted = permissionsMap.values.all { it } + if (allGranted) { + if (bluetoothViewModel.isBluetoothEnabled()) { + if (pendingScan) { + bluetoothViewModel.clearAllErrors() + if (!bluetoothViewModel.isScanning.value) { + bluetoothViewModel.requestStartDeviceScan() + } + pendingScan = false + } + } else { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + enableBluetoothLauncher.launch(enableBtIntent) + } + } else { + pendingScan = false + scope.launch { + sharedViewModel.showSnackbar( + message = context.getString(R.string.bluetooth_permissions_required_for_scan), + duration = SnackbarDuration.Long + ) + } + } + } + + Column( modifier = Modifier .fillMaxSize() @@ -156,6 +186,7 @@ fun BluetoothScreen( // Status and action area (Scan button or info cards) if (!hasPermissions) { PermissionRequestCard(onGrantPermissions = { + pendingScan = true permissionsLauncher.launch( arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) ) @@ -203,8 +234,26 @@ fun BluetoothScreen( if (isScanning) { bluetoothViewModel.requestStopDeviceScan() } else { - bluetoothViewModel.clearAllErrors() // Clear previous errors before starting a new scan - bluetoothViewModel.requestStartDeviceScan() + pendingScan = true + bluetoothViewModel.clearAllErrors() + when { + !bluetoothViewModel.permissionsGranted.value -> { + permissionsLauncher.launch( + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + ) + ) + } + !bluetoothViewModel.isBluetoothEnabled() -> { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + enableBluetoothLauncher.launch(enableBtIntent) + } + else -> { + bluetoothViewModel.requestStartDeviceScan() + pendingScan = false + } + } } }, modifier = Modifier.fillMaxWidth(), @@ -440,7 +489,7 @@ fun DeviceCardItem( onClick: () -> Unit ) { val supportColor = if (deviceInfo.isSupported) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - val unknownDeviceName = stringResource(R.string.unknown_device_placeholder) + val unknownDeviceName = stringResource(R.string.unknown_device) ElevatedCard( onClick = onClick, diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index fc43786e..3ab80041 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -190,7 +190,6 @@ Bluetooth muss aktiviert sein, um nach Waagen zu suchen. Gespeicherte Waage: Unbekannt - Unbekanntes Gerät Scan stoppen Nach Waagen suchen Schaltfläche "Nach Waagen suchen" diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 0873193b..d64016ff 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -192,7 +192,6 @@ Bluetooth must be enabled to search for scales. Saved Scale: Unknown - Unknown Device Stop Scan Search for Scales Search for scales button