1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-24 01:03:20 +02:00

Refactors Bluetooth permission handling and connection logic across several parts of the application

This commit is contained in:
oliexdev
2025-08-20 11:23:11 +02:00
parent 9dc61d7e53
commit 5d8d8165fc
8 changed files with 348 additions and 219 deletions

View File

@@ -78,10 +78,6 @@ class ModernScaleAdapter(
) : ScaleCommunicator { ) : ScaleCommunicator {
private val TAG = "ModernScaleAdapter" private val TAG = "ModernScaleAdapter"
private companion object {
const val TAG = "ModernScaleAdapter"
}
private val adapterScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val adapterScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var central: BluetoothCentralManager // Initialisiert in init private lateinit var central: BluetoothCentralManager // Initialisiert in init
@@ -185,35 +181,53 @@ class ModernScaleAdapter(
} }
private fun hasRequiredBluetoothPermissions(): Boolean { private fun hasRequiredBluetoothPermissions(): Boolean {
val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val required = listOf(
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) Manifest.permission.BLUETOOTH_SCAN,
} else { Manifest.permission.BLUETOOTH_CONNECT
listOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION) )
} return required.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
return requiredPermissions.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?) { override fun connect(address: String, scaleUser: ScaleUser?) {
adapterScope.launch { adapterScope.launch {
if (!::central.isInitialized) { // Ensure central exists (may be created after runtime grant)
LogManager.e(TAG, "BluetoothCentralManager nicht initialisiert, wahrscheinlich aufgrund fehlender Berechtigungen.") if (!ensureCentralReady()) {
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Bluetooth nicht initialisiert (Berechtigungen?)")) _eventsFlow.tryEmit(
BluetoothEvent.ConnectionFailed(address, "Bluetooth permissions missing (SCAN/CONNECT)")
)
return@launch return@launch
} }
// Ignore duplicate connects
if (_isConnecting.value || (_isConnected.value && targetAddress == address)) { 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) { if (_isConnected.value && targetAddress == address) {
val deviceName = currentPeripheral?.name ?: "Unbekanntes Gerät" val deviceName = currentPeripheral?.name ?: "Unknown"
_eventsFlow.tryEmit(BluetoothEvent.Connected(deviceName, address)) _eventsFlow.tryEmit(BluetoothEvent.Connected(deviceName, address))
} }
return@launch return@launch
} }
// Switch target: tear down old connection attempt
if ((_isConnected.value || _isConnecting.value) && targetAddress != address) { 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() disconnectLogic()
} }
@@ -222,26 +236,54 @@ class ModernScaleAdapter(
targetAddress = address targetAddress = address
currentScaleUser = scaleUser 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 // Stop any previous scans
central.stopScan() 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 { try {
// Versuche, direkt ein Peripheral-Objekt zu bekommen, falls die Adresse bekannt ist. if (hasScan) {
//Blessed erlaubt auch das Scannen nach Adresse, was oft robuster ist. // Preferred path on 12+: short pre-scan by address
//central.getPeripheral(address) ist eine Option, aber scanForPeripheralsWithAddresses ist oft besser. central.scanForPeripheralsWithAddresses(arrayOf(address))
central.scanForPeripheralsWithAddresses(arrayOf(address)) LogManager.d(TAG, "Pre-scan started for $address")
LogManager.d(TAG, "Scan gestartet für Adresse: $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) { } catch (e: Exception) {
LogManager.e(TAG, "Fehler beim Starten des Scans für $address", e) LogManager.e(TAG, "Failed to start connect/scan for $address", e)
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Scan konnte nicht gestartet werden: ${e.message}")) _eventsFlow.tryEmit(
BluetoothEvent.ConnectionFailed(address, "Failed to start scan/connect: ${e.message}")
)
_isConnecting.value = false _isConnecting.value = false
targetAddress = null targetAddress = null
} }
} }
} }
private val peripheralCallback: BluetoothPeripheralCallback = private val peripheralCallback: BluetoothPeripheralCallback =
object : BluetoothPeripheralCallback() { object : BluetoothPeripheralCallback() {
override fun onServicesDiscovered(peripheral: BluetoothPeripheral) { override fun onServicesDiscovered(peripheral: BluetoothPeripheral) {

View File

@@ -17,14 +17,11 @@
package com.health.openscale.core.bluetooth.scalesJava; package com.health.openscale.core.bluetooth.scalesJava;
import static android.content.Context.LOCATION_SERVICE;
import android.Manifest; import android.Manifest;
import android.bluetooth.le.ScanRecord; import android.bluetooth.le.ScanRecord;
import android.bluetooth.le.ScanResult; import android.bluetooth.le.ScanResult;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.location.LocationManager;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.SparseArray; import android.util.SparseArray;
@@ -136,19 +133,25 @@ public class BluetoothBroadcastScale extends BluetoothCommunication {
@Override @Override
public void connect(String macAddress) { 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 (!canScan) {
LogManager.e(TAG, "Missing BLUETOOTH_SCAN → cannot start LE scan for broadcast data.", null);
if ((ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) || setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR);
(ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED ) && // choose a better string if you have one, this is just a visible hint:
(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || sendMessage(com.health.openscale.R.string.info_bluetooth_connection_error_scale_offline, 0);
(locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) return;
) {
LogManager.d(TAG, "Do LE scan before connecting to device");
central.scanForPeripheralsWithAddresses(new String[]{macAddress});
} }
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);
} }
} }

View File

@@ -18,14 +18,12 @@
package com.health.openscale.core.bluetooth.scalesJava; package com.health.openscale.core.bluetooth.scalesJava;
import static android.bluetooth.BluetoothGatt.GATT_SUCCESS; import static android.bluetooth.BluetoothGatt.GATT_SUCCESS;
import static android.content.Context.LOCATION_SERVICE;
import android.Manifest; import android.Manifest;
import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.le.ScanResult; import android.bluetooth.le.ScanResult;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.location.LocationManager;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.Message; import android.os.Message;
@@ -88,7 +86,7 @@ public abstract class BluetoothCommunication {
public BluetoothCommunication(Context context) public BluetoothCommunication(Context context)
{ {
this.context = context; this.context = context;
this.disconnectHandler = new Handler(); this.disconnectHandler = new Handler(Looper.getMainLooper());
this.stepNr = 0; this.stepNr = 0;
this.stopped = false; this.stopped = false;
this.central = new BluetoothCentralManager(context, bluetoothCentralCallback, new Handler(Looper.getMainLooper())); 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 * @param macAddress the Bluetooth address to connect to
*/ */
public void connect(String macAddress) { public void connect(String macAddress) {
// Running an LE scan during connect improves connectivity on some phones // Android 12+ (API 31+): SCAN needed for scanning, CONNECT needed for connect/GATT.
// (e.g. Sony Xperia Z5 compact, Android 7.1.1). For some scales (e.g. Medisana BS444) final boolean isSPlus = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S;
// 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);
if ((ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) || if (isSPlus) {
(ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED ) && boolean canConnect = ContextCompat.checkSelfPermission(
(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED;
(locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER))) boolean canScan = ContextCompat.checkSelfPermission(
) { context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED;
LogManager.d("BluetoothCommunication","Do LE scan before connecting to device");
central.scanForPeripheralsWithAddresses(new String[]{macAddress}); if (!canConnect) {
stopMachineState(); LogManager.e("BluetoothCommunication",
} "API≥31: Missing BLUETOOTH_CONNECT → cannot connect/GATT. Aborting.", null);
else { setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR);
LogManager.d("BluetoothCommunication","No location permission, connecting without LE scan"); sendMessage(R.string.info_bluetooth_connection_error_scale_offline, 0);
BluetoothPeripheral peripheral = central.getPeripheral(macAddress); return;
connectToDevice(peripheral); }
// 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) { private void connectToDevice(BluetoothPeripheral peripheral) {
Handler handler = new Handler(); Handler handler = new Handler();

View File

@@ -23,7 +23,6 @@ import android.app.Application
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -280,36 +279,6 @@ class BluetoothViewModel(
// --- Connection Control --- // --- 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. * Attempts to connect to the saved preferred Bluetooth scale.
* Retrieves device info from [userSettingsRepository] and then delegates * Retrieves device info from [userSettingsRepository] and then delegates
@@ -511,20 +480,34 @@ class BluetoothViewModel(
/** /**
* Checks if the necessary Bluetooth permissions are currently granted. * 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. * @return `true` if permissions are granted, `false` otherwise.
*/ */
private fun checkInitialPermissions(): Boolean { private fun checkInitialPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val hasConnect = ContextCompat.checkSelfPermission(
// Android 12 (API 31) and above require BLUETOOTH_SCAN and BLUETOOTH_CONNECT application, Manifest.permission.BLUETOOTH_CONNECT
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && ) == PackageManager.PERMISSION_GRANTED
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else { val hasScan = ContextCompat.checkSelfPermission(
// For older Android versions (below S / API 31) application, Manifest.permission.BLUETOOTH_SCAN
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED && ) == PackageManager.PERMISSION_GRANTED
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(application, Manifest.permission.ACCESS_FINE_LOCATION) == 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
} }
/** /**

View File

@@ -17,8 +17,16 @@
*/ */
package com.health.openscale.ui.screen.overview 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.Context
import android.content.Intent
import android.widget.Toast 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.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -137,31 +145,47 @@ fun determineBluetoothTopBarAction(
currentNavController: NavController, currentNavController: NavController,
bluetoothViewModel: BluetoothViewModel, bluetoothViewModel: BluetoothViewModel,
sharedViewModel: SharedViewModel, sharedViewModel: SharedViewModel,
currentDeviceName: String? currentDeviceName: String?,
permissionsLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, @JvmSuppressWildcards Boolean>>,
enableBluetoothLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>
): SharedViewModel.TopBarAction? { ): 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) 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 { return when {
// Case 1: Connection or disconnection process is actively running // 1) Show non-interactive feedback while a user-initiated operation is ongoing
btConnectingOrDisconnecting -> SharedViewModel.TopBarAction( isBusy -> SharedViewModel.TopBarAction(
icon = Icons.AutoMirrored.Filled.BluetoothSearching, // Icon for "searching" or "working" icon = Icons.AutoMirrored.Filled.BluetoothSearching,
contentDescription = context.getString(R.string.bluetooth_action_connecting_disconnecting_desc), contentDescription = context.getString(R.string.bluetooth_action_connecting_disconnecting_desc),
onClick = { onClick = {
// Typically, the button is not interactive during this time,
// but a Snackbar can confirm the ongoing process.
sharedViewModel.showSnackbar( sharedViewModel.showSnackbar(
message = context.getString( message = context.getString(
when (connStatusEnum) { 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 ConnectionStatus.DISCONNECTING -> R.string.snackbar_bluetooth_disconnecting_from
else -> R.string.snackbar_bluetooth_processing_with // Fallback else -> R.string.snackbar_bluetooth_processing_with
}, },
deviceNameForMessage 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( 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), contentDescription = context.getString(R.string.bluetooth_action_no_scale_saved_desc),
onClick = { onClick = {
sharedViewModel.showSnackbar( 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( 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), contentDescription = context.getString(R.string.bluetooth_action_disconnect_desc, deviceNameForMessage),
onClick = { onClick = {
// Trigger the action first, then show the Snackbar bluetoothViewModel.disconnectDevice()
bluetoothViewModel.disconnectDevice() // IMPORTANT: Trigger disconnection here!
sharedViewModel.showSnackbar( 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 duration = SnackbarDuration.Short
) )
} }
) )
// Case 4: Connection error, and an address is saved // 4) Disconnected / Idle / None / Failed → guarded connect (request first, connect later via callbacks)
connStatusEnum == ConnectionStatus.FAILED && savedAddr != null -> SharedViewModel.TopBarAction( savedAddr != null && (
icon = Icons.Filled.Error, // Error icon connStatusEnum == ConnectionStatus.DISCONNECTED ||
contentDescription = context.getString(R.string.bluetooth_action_retry_connection_desc, deviceNameForMessage), connStatusEnum == ConnectionStatus.IDLE ||
onClick = { connStatusEnum == ConnectionStatus.NONE ||
sharedViewModel.showSnackbar( connStatusEnum == ConnectionStatus.FAILED
message = context.getString(R.string.snackbar_bluetooth_retry_connection, deviceNameForMessage), ) -> SharedViewModel.TopBarAction(
duration = SnackbarDuration.Short icon = Icons.Filled.BluetoothDisabled,
)
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"
contentDescription = context.getString(R.string.bluetooth_action_connect_to_desc, deviceNameForMessage), 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( sharedViewModel.showSnackbar(
message = context.getString(R.string.snackbar_bluetooth_attempting_connection, deviceNameForMessage), message = context.getString(R.string.snackbar_bluetooth_attempting_connection, deviceNameForMessage),
duration = SnackbarDuration.Short duration = SnackbarDuration.Short
@@ -237,43 +249,18 @@ fun determineBluetoothTopBarAction(
} }
) )
// Fallback: If an address is saved, but the state was not specifically covered above, // 5) Fallback
// offer to connect. Ideally, this shouldn't be hit often if the logic above is complete. else -> SharedViewModel.TopBarAction(
// If no device is saved and there's no error/connection attempt, icon = Icons.Default.Bluetooth,
// this was already covered by 'savedAddr == null' (leads to settings). contentDescription = context.getString(R.string.bluetooth_action_check_settings_desc),
else -> { onClick = {
if (savedAddr != null) { sharedViewModel.showSnackbar(
// This serves as a generic "Connect" button if a rare state occurs message = context.getString(R.string.snackbar_bluetooth_check_settings),
SharedViewModel.TopBarAction( duration = SnackbarDuration.Short
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)
}
) )
currentNavController.navigate(Routes.BLUETOOTH_SETTINGS)
} }
} )
} }
} }
@@ -330,6 +317,51 @@ fun OverviewScreen(
val connectedDeviceAddr by bluetoothViewModel.connectedDeviceAddress.collectAsState() val connectedDeviceAddr by bluetoothViewModel.connectedDeviceAddress.collectAsState()
val savedDeviceNameString by bluetoothViewModel.savedScaleName.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 // Determine the Bluetooth action for the top bar
val bluetoothTopBarAction = determineBluetoothTopBarAction( val bluetoothTopBarAction = determineBluetoothTopBarAction(
context = context, context = context,
@@ -339,7 +371,9 @@ fun OverviewScreen(
currentNavController = navController, currentNavController = navController,
bluetoothViewModel = bluetoothViewModel, bluetoothViewModel = bluetoothViewModel,
sharedViewModel = sharedViewModel, sharedViewModel = sharedViewModel,
currentDeviceName = savedDeviceNameString currentDeviceName = savedDeviceNameString,
permissionsLauncher = permissionsLauncher,
enableBluetoothLauncher = enableBluetoothLauncher
) )
// LaunchedEffect to configure the top bar based on the current state // LaunchedEffect to configure the top bar based on the current state

View File

@@ -56,9 +56,13 @@ import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -94,31 +98,15 @@ fun BluetoothScreen(
val scanError by bluetoothViewModel.scanError.collectAsState() val scanError by bluetoothViewModel.scanError.collectAsState()
val connectionError by bluetoothViewModel.connectionError.collectAsState() val connectionError by bluetoothViewModel.connectionError.collectAsState()
val hasPermissions by bluetoothViewModel.permissionsGranted.collectAsState() val hasPermissions by bluetoothViewModel.permissionsGranted.collectAsState()
var pendingScan by remember { mutableStateOf(false) }
val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState() val savedDeviceAddress by bluetoothViewModel.savedScaleAddress.collectAsState()
val savedDeviceName by bluetoothViewModel.savedScaleName.collectAsState() val savedDeviceName by bluetoothViewModel.savedScaleName.collectAsState()
val permissionsLauncher = rememberLauncherForActivityResult( DisposableEffect(Unit) {
contract = ActivityResultContracts.RequestMultiplePermissions() onDispose {
) { permissionsMap -> pendingScan = false
bluetoothViewModel.refreshPermissionsStatus() bluetoothViewModel.requestStopDeviceScan()
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
)
}
} }
} }
@@ -127,7 +115,19 @@ fun BluetoothScreen(
) { result -> ) { result ->
bluetoothViewModel.refreshPermissionsStatus() bluetoothViewModel.refreshPermissionsStatus()
if (result.resultCode == Activity.RESULT_OK) { 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 { scope.launch {
sharedViewModel.showSnackbar( sharedViewModel.showSnackbar(
message = context.getString(R.string.bluetooth_enabled_permissions_missing), 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -156,6 +186,7 @@ fun BluetoothScreen(
// Status and action area (Scan button or info cards) // Status and action area (Scan button or info cards)
if (!hasPermissions) { if (!hasPermissions) {
PermissionRequestCard(onGrantPermissions = { PermissionRequestCard(onGrantPermissions = {
pendingScan = true
permissionsLauncher.launch( permissionsLauncher.launch(
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
) )
@@ -203,8 +234,26 @@ fun BluetoothScreen(
if (isScanning) { if (isScanning) {
bluetoothViewModel.requestStopDeviceScan() bluetoothViewModel.requestStopDeviceScan()
} else { } else {
bluetoothViewModel.clearAllErrors() // Clear previous errors before starting a new scan pendingScan = true
bluetoothViewModel.requestStartDeviceScan() 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(), modifier = Modifier.fillMaxWidth(),
@@ -440,7 +489,7 @@ fun DeviceCardItem(
onClick: () -> Unit onClick: () -> Unit
) { ) {
val supportColor = if (deviceInfo.isSupported) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) 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( ElevatedCard(
onClick = onClick, onClick = onClick,

View File

@@ -190,7 +190,6 @@
<string name="bluetooth_must_be_enabled_for_scan">Bluetooth muss aktiviert sein, um nach Waagen zu suchen.</string> <string name="bluetooth_must_be_enabled_for_scan">Bluetooth muss aktiviert sein, um nach Waagen zu suchen.</string>
<string name="saved_scale_label">Gespeicherte Waage:</string> <string name="saved_scale_label">Gespeicherte Waage:</string>
<string name="unknown_device">Unbekannt</string> <string name="unknown_device">Unbekannt</string>
<string name="unknown_device_placeholder">Unbekanntes Gerät</string>
<string name="stop_scan_button">Scan stoppen</string> <string name="stop_scan_button">Scan stoppen</string>
<string name="search_for_scales_button">Nach Waagen suchen</string> <string name="search_for_scales_button">Nach Waagen suchen</string>
<string name="search_for_scales_button_desc">Schaltfläche "Nach Waagen suchen"</string> <string name="search_for_scales_button_desc">Schaltfläche "Nach Waagen suchen"</string>

View File

@@ -192,7 +192,6 @@
<string name="bluetooth_must_be_enabled_for_scan">Bluetooth must be enabled to search for scales.</string> <string name="bluetooth_must_be_enabled_for_scan">Bluetooth must be enabled to search for scales.</string>
<string name="saved_scale_label">Saved Scale:</string> <string name="saved_scale_label">Saved Scale:</string>
<string name="unknown_device">Unknown</string> <string name="unknown_device">Unknown</string>
<string name="unknown_device_placeholder">Unknown Device</string>
<string name="stop_scan_button">Stop Scan</string> <string name="stop_scan_button">Stop Scan</string>
<string name="search_for_scales_button">Search for Scales</string> <string name="search_for_scales_button">Search for Scales</string>
<string name="search_for_scales_button_desc">Search for scales button</string> <string name="search_for_scales_button_desc">Search for scales button</string>