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:
@@ -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) {
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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();
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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.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
|
||||
|
@@ -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,
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user