1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-23 16:53:04 +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 {
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)
val required = listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
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?) {
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.
if (hasScan) {
// Preferred path on 12+: short pre-scan by address
central.scanForPeripheralsWithAddresses(arrayOf(address))
LogManager.d(TAG, "Scan gestartet für Adresse: $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) {

View File

@@ -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);
}
}

View File

@@ -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");
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();
}
else {
LogManager.d("BluetoothCommunication","No location permission, connecting without LE scan");
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();

View File

@@ -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
}
/**

View File

@@ -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<Array<String>, Map<String, @JvmSuppressWildcards Boolean>>,
enableBluetoothLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>
): 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.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,71 +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"
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()
}
)
// 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(
// 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
@@ -255,13 +248,9 @@ fun determineBluetoothTopBarAction(
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(
// 5) Fallback
else -> SharedViewModel.TopBarAction(
icon = Icons.Default.Bluetooth,
contentDescription = context.getString(R.string.bluetooth_action_check_settings_desc),
onClick = {
@@ -274,8 +263,6 @@ fun determineBluetoothTopBarAction(
)
}
}
}
}
/**
* The main screen for displaying an overview of measurements, user status, and Bluetooth controls.
@@ -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

View File

@@ -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
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,

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="saved_scale_label">Gespeicherte Waage:</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="search_for_scales_button">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="saved_scale_label">Saved Scale:</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="search_for_scales_button">Search for Scales</string>
<string name="search_for_scales_button_desc">Search for scales button</string>