From 5bef95e613e4ba0b79cee96f90b7107fe33d14ac Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 15 Aug 2025 07:30:24 +0200 Subject: [PATCH] - Streamlined the handling of user input dialogs (`CHOOSE_USER`, `ENTER_CONSENT`) in `AppNavigation.kt` by resetting dialog-specific state based on `interactionEvent.interactionType` to ensure fresh state upon dialog visibility. - Introduced `pendingUserId` to better manage user context during asynchronous operations like consent requests. - Improved logging for user registration and selection steps. --- .../BluetoothStandardWeightProfile.java | 65 +++- .../openscale/ui/navigation/AppNavigation.kt | 25 +- .../ui/screen/bluetooth/BluetoothViewModel.kt | 307 +++++------------- .../app/src/main/res/values-de/strings.xml | 21 ++ .../app/src/main/res/values/strings.xml | 21 ++ 5 files changed, 181 insertions(+), 258 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java index bcbaa67e..3b05653b 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java @@ -68,6 +68,7 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat SharedPreferences prefs; protected boolean registerNewUser; ScaleUser selectedUser; + private int pendingUserId = -1; ScaleMeasurement previousMeasurement; protected boolean haveBatteryService; protected Vector scaleUserList; @@ -174,8 +175,10 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat break; case REGISTER_NEW_SCALE_USER: int userId = this.selectedUser.getId(); + pendingUserId = userId; int consentCode = getUserScaleConsent(userId); int userIndex = getUserScaleIndex(userId); + LogManager.d(TAG, "Step register new scale user, userId: " + userId + ", consentCode: " + consentCode + ", userIndex: " + userIndex); if (consentCode == -1 || userIndex == -1) { registerNewUser = true; } @@ -188,8 +191,13 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat } break; case SELECT_SCALE_USER: - LogManager.d(TAG, "Select user on scale!"); - setUser(this.selectedUser.getId()); + int userIdToUse = (pendingUserId != -1) ? pendingUserId : this.selectedUser.getId(); + if (userIdToUse != this.selectedUser.getId()) { + LogManager.w(TAG, "SELECT_SCALE_USER: Using pendingUserId=" + userIdToUse + " (selectedUserId=" + this.selectedUser.getId() + " differs).", null); + } else { + LogManager.d(TAG, "SELECT_SCALE_USER: Using selectedUserId=" + userIdToUse); + } + setUser(userIdToUse); stopMachineState(); break; case SET_SCALE_USER_DATA: @@ -293,6 +301,7 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat } if (value[2] == UDS_CP_RESP_VALUE_SUCCESS) { LogManager.d(TAG, "UDS_CP_CONSENT: Success user consent"); + pendingUserId = -1; resumeMachineState(); } else if (value[2] == UDS_CP_RESP_USER_NOT_AUTHORIZED) { LogManager.e(TAG, "UDS_CP_CONSENT: Not authorized", null); @@ -599,7 +608,22 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat protected synchronized void setUser(int userId) { int userIndex = getUserScaleIndex(userId); int consentCode = getUserScaleConsent(userId); - LogManager.d(TAG, String.format("setting: userId %d, userIndex: %d, consent Code: %d ", userId, userIndex, consentCode)); + LogManager.d(TAG, "setUser(appUserId=" + userId + ") with index=" + userIndex + " consent=" + consentCode + + " (selectedUserId=" + (this.selectedUser != null ? this.selectedUser.getId() : -1) + + ", pendingUserId=" + pendingUserId + ")"); + + if (userIndex == -1) { + LogManager.w(TAG, "setUser: no scale index for appUserId=" + userId + ". Requesting vendor-specific user list.", null); + jumpNextToStepNr(SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST.ordinal()); + stopMachineState(); + requestVendorSpecificUserList(); + return; + } + if (consentCode == -1) { + LogManager.w(TAG, "setUser: missing consent for appUserId=" + userId + " (index=" + userIndex + "). Requesting consent.", null); + requestScaleUserConsent(userId, userIndex); + return; + } setUser(userIndex, consentCode); } @@ -678,6 +702,7 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat protected synchronized void storeUserScaleConsentCode(int userId, int consentCode) { prefs.edit().putInt("userConsentCode" + userId, consentCode).apply(); + LogManager.d(TAG, "storeUserScaleConsentCode: userId=" + userId + " now=" + getUserScaleConsent(userId)); } protected synchronized int getUserScaleConsent(int userId) { @@ -687,12 +712,13 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat protected synchronized void storeUserScaleIndex(int userId, int userIndex) { int currentUserIndex = getUserScaleIndex(userId); if (currentUserIndex != -1) { - prefs.edit().putInt("userIdFromUserScaleIndex" + currentUserIndex, -1); + prefs.edit().putInt("userIdFromUserScaleIndex" + currentUserIndex, -1).apply(); } prefs.edit().putInt("userScaleIndex" + userId, userIndex).apply(); if (userIndex != -1) { prefs.edit().putInt("userIdFromUserScaleIndex" + userIndex, userId).apply(); } + LogManager.d(TAG, "storeUserScaleIndex: userId=" + userId + " now=" + getUserScaleIndex(userId)); } protected synchronized int getUserIdFromScaleIndex(int userScaleIndex) { @@ -717,13 +743,17 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat } protected void requestScaleUserConsent(int appScaleUserId, int scaleUserIndex) { + pendingUserId = appScaleUserId; + LogManager.d(TAG, "requestScaleUserConsent(appUserId=" + appScaleUserId + ", scaleIndex=" + scaleUserIndex + "), pendingUserId=" + pendingUserId); Object[] consentRequestData = new Object[]{appScaleUserId, scaleUserIndex}; requestUserInteraction(UserInteractionType.ENTER_CONSENT, consentRequestData); } @Override public void processUserInteractionFeedback(UserInteractionType interactionType, int appUserId, Object feedbackData, Handler uiHandler) { - LogManager.d(TAG, "Processing UserInteractionFeedback: " + interactionType + " for appUserId: " + appUserId); + pendingUserId = appUserId; + LogManager.d(TAG, "Processing UserInteractionFeedback: " + interactionType + " for appUserId=" + appUserId + " (pendingUserId set)"); + switch (interactionType) { case CHOOSE_USER: if (feedbackData instanceof Integer) { @@ -748,6 +778,8 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat int scaleUserConsent = (Integer) feedbackData; LogManager.d(TAG, "ENTER_CONSENT Feedback: scaleUserConsent = " + scaleUserConsent); storeUserScaleConsentCode(appUserId, scaleUserConsent); + LogManager.d(TAG, "after_enter_consent_store: appUserId=" + appUserId + ", pendingUserId=" + pendingUserId + + ", selectedUserId=" + (this.selectedUser != null ? this.selectedUser.getId() : -1)); if (scaleUserConsent == -1) { // User cancelled or denied consent reconnectOrSetSmState(SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); } else { // User provided consent @@ -769,8 +801,20 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat int userListStatus = parser.getIntValue(FORMAT_UINT8); if (userListStatus == 2) { LogManager.d(TAG, "scale have no users!"); - storeUserScaleConsentCode(selectedUser.getId(), -1); - storeUserScaleIndex(selectedUser.getId(), -1); + int uid = selectedUser.getId(); + + int oldConsent = getUserScaleConsent(uid); + if (oldConsent != -1) { + LogManager.w(TAG, "Status=2 -> resetting consent for userId=" + uid + " from " + oldConsent + " to -1", null); + storeUserScaleConsentCode(uid, -1); + } + + int oldIndex = getUserScaleIndex(uid); + if (oldIndex != -1) { + LogManager.w(TAG, "Status=2 -> resetting index for userId=" + uid + " from " + oldIndex + " to -1", null); + storeUserScaleIndex(uid, -1); + } + jumpNextToStepNr(SM_STEPS.REGISTER_NEW_SCALE_USER.ordinal()); resumeMachineState(); return; @@ -782,9 +826,8 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat } LogManager.d(TAG, "\n" + (i + 1) + ". " + scaleUserList.get(i)); } - if ((scaleUserList.size() == 0)) { - storeUserScaleConsentCode(selectedUser.getId(), -1); - storeUserScaleIndex(selectedUser.getId(), -1); + if (scaleUserList.size() == 0) { + LogManager.w(TAG, "status=1 but user list empty; skipping forced reset of consent/index.", null); jumpNextToStepNr(SM_STEPS.REGISTER_NEW_SCALE_USER.ordinal()); resumeMachineState(); return; @@ -889,7 +932,7 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat LogManager.e(TAG, "CHOOSE_USER: choiceStrings or indexArray is null. Cannot request user interaction.", null); } - Pair choices = new Pair(choiceStrings, indexArray); + Pair choices = new Pair<>(choiceStrings, indexArray); requestUserInteraction(UserInteractionType.CHOOSE_USER, choices); } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt index 1c6f5296..e65f7057 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt @@ -247,25 +247,17 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { val dialogTitle: String val dialogIcon: @Composable (() -> Unit) - var consentCodeInput by rememberSaveable { mutableStateOf("") } - var selectedUserIndexState by rememberSaveable { mutableIntStateOf(Int.MIN_VALUE) } + var consentCodeInput by rememberSaveable(interactionEvent.interactionType) { mutableStateOf("") } + var selectedUserIndexState by rememberSaveable(interactionEvent.interactionType) { mutableIntStateOf(Int.MIN_VALUE) } when (interactionEvent.interactionType) { UserInteractionType.CHOOSE_USER -> { dialogTitle = stringResource(R.string.dialog_bt_interaction_title_choose_user) dialogIcon = { Icon(Icons.Filled.People, contentDescription = stringResource(R.string.dialog_bt_icon_desc_choose_user)) } - // Reset state when dialog becomes visible for CHOOSE_USER - if (interactionEvent.interactionType == UserInteractionType.CHOOSE_USER) { - selectedUserIndexState = Int.MIN_VALUE - } } UserInteractionType.ENTER_CONSENT -> { dialogTitle = stringResource(R.string.dialog_bt_interaction_title_enter_consent) dialogIcon = { Icon(Icons.Filled.HowToReg, contentDescription = stringResource(R.string.dialog_bt_icon_desc_enter_consent)) } - // Reset state when dialog becomes visible for ENTER_CONSENT - if (interactionEvent.interactionType == UserInteractionType.ENTER_CONSENT) { - consentCodeInput = "" - } } // else -> { /* Handle unknown types or provide defaults */ } // Optional } @@ -276,9 +268,6 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { AlertDialog( onDismissRequest = { - if (interactionEvent.interactionType == UserInteractionType.ENTER_CONSENT) { - bluetoothViewModel.processUserInteraction(interactionEvent.interactionType, -1) // -1 abort signal - } bluetoothViewModel.clearPendingUserInteraction() }, icon = dialogIcon, @@ -301,9 +290,9 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { LogManager.d(TAG, "CHOOSE_USER interaction received. Data: $choicesData") // Expecting Pair, IntArray> or Pair, IntArray> - if (choicesData is Pair<*, *> && choicesData.first is Array<*> && choicesData.second is IntArray) { + if (choicesData is android.util.Pair<*, *> && choicesData.first is Array<*> && choicesData.second is IntArray) { @Suppress("UNCHECKED_CAST") - val choices = choicesData as Pair, IntArray> + val choices = choicesData as android.util.Pair, IntArray> val choiceDisplayNames = choices.first val choiceIndices = choices.second @@ -395,11 +384,7 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { dismissButton = { TextButton( onClick = { - if (interactionEvent.interactionType == UserInteractionType.ENTER_CONSENT) { - bluetoothViewModel.processUserInteraction(interactionEvent.interactionType, -1) // -1 for abort signal - } else { - bluetoothViewModel.clearPendingUserInteraction() - } + bluetoothViewModel.clearPendingUserInteraction() } ) { Text(stringResource(R.string.cancel_button)) diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt index 1b66c88e..0c4668a0 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt @@ -26,24 +26,18 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Handler import android.os.Looper -import androidx.compose.material3.SnackbarDuration // Keep if used directly, otherwise remove +import androidx.compose.material3.SnackbarDuration import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent -// ScaleCommunicator no longer needed directly here import com.health.openscale.core.bluetooth.ScaleFactory import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType -// ScaleMeasurement no longer needed directly here for saveMeasurementFromEvent import com.health.openscale.core.bluetooth.data.ScaleUser -import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication -// Measurement, MeasurementTypeKey, MeasurementValue no longer needed directly here import com.health.openscale.core.data.User import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.screen.SharedViewModel -// kotlinx.coroutines.Dispatchers no longer needed directly here for saveMeasurement -// kotlinx.coroutines.Job no longer needed directly here for communicatorJob -// kotlinx.coroutines.delay no longer needed directly here for disconnect-timeout import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -59,60 +53,33 @@ import java.util.Date * Represents the various states of a Bluetooth connection. */ enum class ConnectionStatus { - /** No connection activity. */ - NONE, - /** Bluetooth adapter is present and enabled, but not actively scanning or connected. */ - IDLE, - /** No active connection to a device. */ - DISCONNECTED, - /** Attempting to establish a connection to a device. */ - CONNECTING, - /** Successfully connected to a device. */ - CONNECTED, - /** In the process of disconnecting from a device. */ - DISCONNECTING, - /** A connection attempt or an established connection has failed. */ - FAILED + NONE, IDLE, DISCONNECTED, CONNECTING, CONNECTED, DISCONNECTING, FAILED } /** - * ViewModel responsible for managing Bluetooth interactions, including device scanning, - * connection, and data handling. It coordinates with [BluetoothScannerManager] for scanning - * and [BluetoothConnectionManager] for connection lifecycle and data events. - * - * This ViewModel also manages user context relevant to Bluetooth operations and exposes - * StateFlows for UI observation. - * - * @param application The application context. - * @param sharedViewModel A [SharedViewModel] instance for accessing shared resources like - * repositories and for displaying global UI messages (e.g., Snackbars). + * ViewModel for Bluetooth interactions: scanning, connection, data handling. + * Coordinates with [BluetoothScannerManager] and [BluetoothConnectionManager]. */ class BluetoothViewModel( - private val application: Application, + private val application: Application, // Used for context and string resources val sharedViewModel: SharedViewModel ) : ViewModel() { private companion object { const val TAG = "BluetoothViewModel" - const val SCAN_DURATION_MS = 20000L // Default scan duration: 20 seconds + const val SCAN_DURATION_MS = 20000L } - // Access to repositories is passed to the managers. private val databaseRepository = sharedViewModel.databaseRepository val userSettingsRepository = sharedViewModel.userSettingRepository - // --- User Context (managed by ViewModel, used by ConnectionManager) --- private var currentAppUser: User? = null - private var currentBtScaleUser: ScaleUser? = null // Derived from currentAppUser for Bluetooth operations + private var currentBtScaleUser: ScaleUser? = null private var currentAppUserId: Int = 0 - // --- Dependencies (ScaleFactory is passed to managers) --- private val scaleFactory = ScaleFactory(application.applicationContext, databaseRepository) - - // --- BluetoothScannerManager (manages device scanning) --- private val bluetoothScannerManager = BluetoothScannerManager(application, viewModelScope, scaleFactory) - // --- BluetoothConnectionManager (manages device connection and data events) --- private val bluetoothConnectionManager = BluetoothConnectionManager( context = application.applicationContext, scope = viewModelScope, @@ -121,76 +88,51 @@ class BluetoothViewModel( sharedViewModel = sharedViewModel, getCurrentScaleUser = { currentBtScaleUser }, onSavePreferredDevice = { address, name -> - // Save preferred device when ConnectionManager successfully connects and indicates to do so. - // Snackbar for user feedback can be shown here or in ConnectionManager; here is fine. - viewModelScope.launch { - userSettingsRepository.saveBluetoothScale(address, name) - sharedViewModel.showSnackbar("$name saved as preferred scale.", SnackbarDuration.Short) - } + // Snackbar for user feedback when a device is set as preferred by ConnectionManager + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_scale_saved_as_preferred, name), + SnackbarDuration.Short + ) } ) - // --- Scan State Flows (from BluetoothScannerManager) --- - /** Emits the list of discovered Bluetooth devices. */ val scannedDevices: StateFlow> = bluetoothScannerManager.scannedDevices - /** Emits `true` if a Bluetooth scan is currently active, `false` otherwise. */ val isScanning: StateFlow = bluetoothScannerManager.isScanning - /** Emits error messages related to the scanning process, or null if no error. */ val scanError: StateFlow = bluetoothScannerManager.scanError - // --- Connection State Flows (from BluetoothConnectionManager) --- - /** Emits the MAC address of the currently connected device, or null if not connected. */ val connectedDeviceAddress: StateFlow = bluetoothConnectionManager.connectedDeviceAddress - /** Emits the current [ConnectionStatus] of the Bluetooth device. */ val connectionStatus: StateFlow = bluetoothConnectionManager.connectionStatus - /** Emits connection-related error messages, or null if no error. */ val connectionError: StateFlow = bluetoothConnectionManager.connectionError - - // --- Permissions and System State (managed by ViewModel) --- private val _permissionsGranted = MutableStateFlow(checkInitialPermissions()) - /** Emits `true` if all necessary Bluetooth permissions are granted, `false` otherwise. */ val permissionsGranted: StateFlow = _permissionsGranted.asStateFlow() - // --- Saved Device Info (for UI display and auto-connect logic) --- - /** Emits the MAC address of the saved preferred Bluetooth scale, or null if none is saved. */ val savedScaleAddress: StateFlow = userSettingsRepository.savedBluetoothScaleAddress .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) - /** Emits the name of the saved preferred Bluetooth scale, or null if none is saved. */ val savedScaleName: StateFlow = userSettingsRepository.savedBluetoothScaleName .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) - // --- UI Interaction for User Selection (triggered by ConnectionManager callback) --- val pendingUserInteractionEvent: StateFlow = bluetoothConnectionManager.userInteractionRequiredEvent init { LogManager.i(TAG, "ViewModel initialized. Setting up user observation.") observeUserChanges() - // attemptAutoConnectToSavedScale() // Can be enabled if auto-connect on ViewModel init is desired. } - /** - * Observes changes to the selected application user and updates the Bluetooth user context accordingly. - * This ensures that operations like saving measurements or providing user data to the scale - * use the correct user profile. - */ private fun observeUserChanges() { viewModelScope.launch { - // Observe user selected via SharedViewModel (e.g., user picker in UI) sharedViewModel.selectedUser.filterNotNull().collectLatest { appUser -> LogManager.d(TAG, "User selected via SharedViewModel: ${appUser.name}. Updating context.") updateCurrentUserContext(appUser) } } viewModelScope.launch { - // Fallback: Observe current user ID from settings if no user is selected via SharedViewModel. - // This handles scenarios where the app starts and a default user is already set. if (sharedViewModel.selectedUser.value == null) { userSettingsRepository.currentUserId.filterNotNull().collectLatest { userId -> if (userId != 0) { databaseRepository.getUserById(userId).filterNotNull().firstOrNull()?.let { userDetails -> - if (currentAppUserId != userDetails.id) { // Only update if the user actually changed. + if (currentAppUserId != userDetails.id) { LogManager.d(TAG, "User changed via UserSettingsRepository: ${userDetails.name}. Updating context.") updateCurrentUserContext(userDetails) } @@ -207,10 +149,6 @@ class BluetoothViewModel( } } - /** - * Updates the internal state for the current application user and the corresponding Bluetooth scale user. - * @param appUser The [User] object representing the current application user. - */ private fun updateCurrentUserContext(appUser: User) { currentAppUser = appUser currentAppUserId = appUser.id @@ -218,9 +156,6 @@ class BluetoothViewModel( LogManager.i(TAG, "User context updated for Bluetooth operations: User '${currentBtScaleUser?.userName}' (App ID: ${currentAppUserId})") } - /** - * Clears the current user context. Called when no user is selected or found. - */ private fun clearUserContext() { currentAppUser = null currentAppUserId = 0 @@ -228,72 +163,48 @@ class BluetoothViewModel( LogManager.i(TAG, "User context cleared for Bluetooth operations.") } - /** - * Converts an application [User] object to a [ScaleUser] object, - * which is the format expected by some Bluetooth scale drivers. - * @param appUser The application [User] to convert. - * @return A [ScaleUser] representation. - */ private fun convertAppUserToBtScaleUser(appUser: User): ScaleUser { return ScaleUser().apply { - // Note: ScaleUser.id often corresponds to the on-scale user slot (1-N), - // while appUser.id is the database ID. Some drivers might use appUser.id directly - // if the scale supports arbitrary user identifiers or if we manage mapping externally. - // For now, using appUser.id as a general identifier for the ScaleUser. id = appUser.id userName = appUser.name - birthday = Date(appUser.birthDate) // Ensure birthDate is in millis - bodyHeight = appUser.heightCm ?: 0f // Default to 0f if height is null + birthday = Date(appUser.birthDate) + bodyHeight = appUser.heightCm ?: 0f gender = appUser.gender } } - // --- Scan Control --- - - /** - * Requests the [BluetoothScannerManager] to start scanning for devices. - * Checks for necessary permissions and Bluetooth enabled status before initiating the scan. - */ - @SuppressLint("MissingPermission") // Permissions are checked before calling the manager. + @SuppressLint("MissingPermission") fun requestStartDeviceScan() { LogManager.i(TAG, "User requested to start device scan.") - refreshPermissionsStatus() // Ensure permission state is up-to-date. + refreshPermissionsStatus() if (!permissionsGranted.value) { LogManager.w(TAG, "Scan request denied: Bluetooth permissions missing.") - sharedViewModel.showSnackbar("Bluetooth permissions are required to scan for devices.", SnackbarDuration.Long) + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_permissions_required_to_scan), + SnackbarDuration.Long + ) return } if (!isBluetoothEnabled()) { LogManager.w(TAG, "Scan request denied: Bluetooth is disabled.") - sharedViewModel.showSnackbar("Bluetooth is disabled. Please enable it to scan for devices.", SnackbarDuration.Long) + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_bluetooth_disabled_to_scan), + SnackbarDuration.Long + ) return } - clearAllErrors() // Clear previous scan/connection errors. + clearAllErrors() LogManager.d(TAG, "Prerequisites met. Delegating scan start to BluetoothScannerManager.") bluetoothScannerManager.startScan(SCAN_DURATION_MS) } - /** - * Requests the [BluetoothScannerManager] to stop an ongoing device scan. - */ fun requestStopDeviceScan() { LogManager.i(TAG, "User requested to stop device scan. Delegating to BluetoothScannerManager.") - // The `isTimeout` parameter is an internal detail for the scanner manager; - // from ViewModel's perspective, it's a manual stop request. bluetoothScannerManager.stopScan() } - // --- 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. + @SuppressLint("MissingPermission") fun connectToDevice(deviceInfo: ScannedDeviceInfo) { val deviceDisplayName = deviceInfo.name ?: deviceInfo.address LogManager.i(TAG, "User requested to connect to device: $deviceDisplayName") @@ -301,13 +212,9 @@ class BluetoothViewModel( 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 for errors. return } @@ -315,13 +222,7 @@ class BluetoothViewModel( bluetoothConnectionManager.connectToDevice(deviceInfo) } - - /** - * Attempts to connect to the saved preferred Bluetooth scale. - * Retrieves device info from [userSettingsRepository] and then delegates - * to [BluetoothConnectionManager]. - */ - @SuppressLint("MissingPermission") // Permissions are checked by validateConnectionPrerequisites. + @SuppressLint("MissingPermission") fun connectToSavedDevice() { viewModelScope.launch { val address = savedScaleAddress.value @@ -331,80 +232,70 @@ class BluetoothViewModel( if (isScanning.value) { LogManager.d(TAG, "Scan is active, stopping it before connecting to saved device '$name'.") requestStopDeviceScan() - // delay(200) // Optional delay } if (!validateConnectionPrerequisites(name, isManualConnect = false)) { - // If isManualConnect is false, validateConnectionPrerequisites shows a Snackbar - // but doesn't set an error in ConnectionManager, which is fine for auto-attempts. return@launch } if (address != null && name != null) { - // For a saved device, we need to re-evaluate its support status using ScaleFactory, - // as supported handlers might change with app updates. LogManager.d(TAG, "Re-evaluating support for saved device '$name' ($address) using ScaleFactory.") - val deviceInfoForConnect = ScannedDeviceInfo( - name = name, - address = address, - rssi = 0, // RSSI is not relevant for a direct connection attempt to a saved device. - serviceUuids = emptyList(), - manufacturerData = null, - isSupported = false, // will be determined by getSupportingHandlerInfo - determinedHandlerDisplayName = null // will be determined by getSupportingHandlerInfo + name = name, address = address, rssi = 0, serviceUuids = emptyList(), + manufacturerData = null, isSupported = false, determinedHandlerDisplayName = null ) - val (isPotentiallySupported, handlerNameFromFactory) = scaleFactory.getSupportingHandlerInfo(deviceInfoForConnect) deviceInfoForConnect.isSupported = isPotentiallySupported deviceInfoForConnect.determinedHandlerDisplayName = handlerNameFromFactory if (!deviceInfoForConnect.isSupported) { - LogManager.w(TAG, "Saved device '$name' ($address) is currently not supported by ScaleFactory. Connection aborted.") - // This error is specific to connecting to a *saved* device that's no longer supported. - // The ConnectionManager might not have a dedicated error state for this nuance if it only expects - // ScannedDeviceInfo for connection attempts. Showing a Snackbar is a direct user feedback. - sharedViewModel.showSnackbar("Saved scale '$name' is no longer supported.", SnackbarDuration.Long) - // We don't want to set a generic connectionError in BluetoothConnectionManager here, - // as no connection attempt was made *through* it yet. + LogManager.w(TAG, "Saved device '$name' ($address) is currently not supported. Connection aborted.") + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_saved_scale_no_longer_supported, name), + SnackbarDuration.Long + ) return@launch } LogManager.d(TAG, "Saved device '$name' is supported. Delegating connection to BluetoothConnectionManager.") bluetoothConnectionManager.connectToDevice(deviceInfoForConnect) } else { LogManager.w(TAG, "Attempted to connect to saved device, but no device is saved.") - sharedViewModel.showSnackbar("No Bluetooth scale saved in settings.", SnackbarDuration.Short) + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_no_scale_saved), + SnackbarDuration.Short + ) } } } /** * Validates common prerequisites for initiating a Bluetooth connection. - * Checks for permissions and Bluetooth enabled status. - * - * @param deviceName The name/identifier of the device for logging/messages. - * @param isManualConnect `true` if this is a direct user action to connect, `false` for automated attempts. - * This influences how errors are reported (e.g., setting an error in ConnectionManager vs. just a Snackbar). * @return `true` if all prerequisites are met, `false` otherwise. */ - private fun validateConnectionPrerequisites(deviceName: String?, isManualConnect: Boolean): Boolean { - refreshPermissionsStatus() // Always get the latest permission status. + private fun validateConnectionPrerequisites(deviceNameForMessage: String?, isManualConnect: Boolean): Boolean { + refreshPermissionsStatus() + + val devicePlaceholder = application.getString(R.string.device_placeholder_name) // "the device" if (!permissionsGranted.value) { - val errorMsg = "Bluetooth permissions are required to connect to ${deviceName ?: "the device"}." - LogManager.w(TAG, "Connection prerequisite failed for '${deviceName ?: "device"}': $errorMsg") + val errorMsg = application.getString( + R.string.bt_snackbar_permissions_required_to_connect, + deviceNameForMessage ?: devicePlaceholder + ) + LogManager.w(TAG, "Connection prerequisite failed: $errorMsg") if (isManualConnect) { - // For manual attempts, set an error in the ConnectionManager to reflect in UI state. bluetoothConnectionManager.setExternalConnectionError(errorMsg) } else { - // For automatic attempts (e.g., auto-connect), a Snackbar might be sufficient without altering permanent error state. sharedViewModel.showSnackbar(errorMsg, SnackbarDuration.Long) } return false } if (!isBluetoothEnabled()) { - val errorMsg = "Bluetooth is disabled. Please enable it to connect to ${deviceName ?: "the device"}." - LogManager.w(TAG, "Connection prerequisite failed for '${deviceName ?: "device"}': $errorMsg") + val errorMsg = application.getString( + R.string.bt_snackbar_bluetooth_disabled_to_connect, + deviceNameForMessage ?: devicePlaceholder + ) + LogManager.w(TAG, "Connection prerequisite failed: $errorMsg") if (isManualConnect) { bluetoothConnectionManager.setExternalConnectionError(errorMsg) } else { @@ -412,26 +303,14 @@ class BluetoothViewModel( } return false } - // User ID check is now more nuanced and handled within BluetoothConnectionManager, - // as its necessity can be handler-specific. - // LogManager.d(TAG, "Connection prerequisites met for ${deviceName ?: "device"}.") return true } - - /** - * Requests the [BluetoothConnectionManager] to disconnect from the currently connected device. - */ fun disconnectDevice() { LogManager.i(TAG, "User requested to disconnect device. Delegating to BluetoothConnectionManager.") bluetoothConnectionManager.disconnect() } - // --- Error Handling --- - - /** - * Clears all error states managed by both the scanner and connection managers. - */ fun clearAllErrors() { LogManager.d(TAG, "Clearing all scan and connection errors.") bluetoothScannerManager.clearScanError() @@ -445,67 +324,61 @@ class BluetoothViewModel( fun processUserInteraction(interactionType: UserInteractionType, feedbackData: Any) { viewModelScope.launch { - val currentAppUser = sharedViewModel.selectedUser.value - if (currentAppUser == null || currentAppUser.id == 0) { - sharedViewModel.showSnackbar("Fehler: Kein App-Benutzer ausgewählt.") + val localCurrentAppUser = currentAppUser // Use local copy for thread safety check + if (localCurrentAppUser == null || localCurrentAppUser.id == 0) { + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_error_no_app_user_selected), + SnackbarDuration.Short // Assuming short duration, adjust if needed + ) bluetoothConnectionManager.clearUserInteractionEvent() return@launch } - val appUserId = currentAppUser.id + val appUserId = localCurrentAppUser.id - clearPendingUserInteraction() + // BluetoothConnectionManager now internally uses viewModelScope for its operations, + // so direct Handler passing might be less critical if its methods are suspend or use its own scope. + // If direct MainLooper operations are still needed within provideUserInteractionFeedback: val uiHandler = Handler(Looper.getMainLooper()) bluetoothConnectionManager.provideUserInteractionFeedback( interactionType, appUserId, feedbackData, - uiHandler + uiHandler // Pass if strictly needed by the manager for immediate UI thread tasks ) - sharedViewModel.showSnackbar("Benutzereingabe verarbeitet.", SnackbarDuration.Short) + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_user_input_processed), + SnackbarDuration.Short + ) + + clearPendingUserInteraction() } } - // --- Device Preferences --- - - /** - * Saves the given scanned device as the preferred Bluetooth scale in user settings. - * @param device The [ScannedDeviceInfo] of the device to save. - */ fun saveDeviceAsPreferred(device: ScannedDeviceInfo) { viewModelScope.launch { - val nameToSave = device.name ?: "Unknown Scale" // Provide a default name if null. + val nameToSave = device.name ?: application.getString(R.string.unknown_scale_name) // Default name from resources LogManager.i(TAG, "User requested to save device as preferred: Name='${device.name}', Address='${device.address}'. Saving as '$nameToSave'.") userSettingsRepository.saveBluetoothScale(device.address, nameToSave) - sharedViewModel.showSnackbar("'$nameToSave' saved as preferred scale.", SnackbarDuration.Short) - // The savedScaleAddress/Name flows will update automatically, triggering any observers. + sharedViewModel.showSnackbar( + application.getString(R.string.bt_snackbar_scale_saved_as_preferred, nameToSave), + SnackbarDuration.Short + ) } } - // --- Permissions and System State Methods --- - - /** - * Checks if the necessary Bluetooth permissions are currently granted. - * Handles different permission sets for Android S (API 31) and above vs. older versions. - * @return `true` if permissions are granted, `false` otherwise. - */ private fun checkInitialPermissions(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 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) 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 } } - /** - * Refreshes the `permissionsGranted` StateFlow by re-checking the current permission status. - * Should be called when the app regains focus or when permissions might have changed. - */ fun refreshPermissionsStatus() { val currentStatus = checkInitialPermissions() if (_permissionsGranted.value != currentStatus) { @@ -514,26 +387,12 @@ class BluetoothViewModel( } } - /** - * Checks if the Bluetooth adapter is currently enabled on the device. - * @return `true` if Bluetooth is enabled, `false` otherwise. - */ fun isBluetoothEnabled(): Boolean { val bluetoothManager = application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? - val isEnabled = bluetoothManager?.adapter?.isEnabled ?: false - // LogManager.v(TAG, "Bluetooth enabled status check: $isEnabled") // Potentially too verbose for frequent checks - return isEnabled + return bluetoothManager?.adapter?.isEnabled ?: false } - // Logic for handling Bluetooth events directly, saving measurements, observing communicator, - // and releasing communicator has been moved to BluetoothConnectionManager. - - /** - * Attempts to automatically connect to the saved preferred Bluetooth scale, if one exists - * and the app is not already connected or connecting to it. - * This might be called on ViewModel initialization or when the app comes to the foreground. - */ - @SuppressLint("MissingPermission") // connectToSavedDevice handles permission checks. + @SuppressLint("MissingPermission") fun attemptAutoConnectToSavedScale() { viewModelScope.launch { val address = savedScaleAddress.value @@ -541,27 +400,21 @@ class BluetoothViewModel( if (address != null && name != null) { LogManager.i(TAG, "Attempting auto-connect to saved scale: '$name' ($address).") - // Check if already connected or connecting to the target device. if ((connectionStatus.value == ConnectionStatus.CONNECTED || connectionStatus.value == ConnectionStatus.CONNECTING) && connectedDeviceAddress.value == address ) { LogManager.d(TAG, "Auto-connect: Already connected or connecting to '$name' ($address). No action needed.") return@launch } - // Delegate to the standard method for connecting to a saved device. connectToSavedDevice() } else { LogManager.d(TAG, "Auto-connect attempt: No saved scale found.") + // Optionally show a (non-blocking) snackbar if desired, though usually auto-attempts are silent on "not found" + // sharedViewModel.showSnackbar(application.getString(R.string.bt_snackbar_no_scale_saved), SnackbarDuration.Short) } } } - - /** - * Called when the ViewModel is about to be destroyed. - * Ensures that resources used by Bluetooth managers are released (e.g., stopping scans, - * disconnecting devices, closing underlying Bluetooth resources). - */ override fun onCleared() { super.onCleared() LogManager.i(TAG, "BluetoothViewModel onCleared. Releasing resources from managers.") diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index e15af571..87664dfc 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -381,6 +381,27 @@ Gesamte Datenbank wurde gelöscht. Fehler beim Löschen der gesamten Datenbank. + + Bluetooth-Berechtigungen werden zum Scannen von Geräten benötigt. + Bluetooth ist deaktiviert. Bitte aktiviere es, um nach Geräten zu scannen. + + Bluetooth-Berechtigungen werden benötigt, um eine Verbindung zu %1$s herzustellen. + Bluetooth-Berechtigungen werden benötigt, um eine Verbindung zum Gerät herzustellen. + Bluetooth ist deaktiviert. Bitte aktiviere es, um eine Verbindung zu %1$s herzustellen. + Bluetooth ist deaktiviert. Bitte aktiviere es, um eine Verbindung zum Gerät herzustellen. + + %1$s als bevorzugte Waage gespeichert. + Gespeicherte Waage \'%1$s\' wird nicht mehr unterstützt. + Keine Bluetooth-Waage in den Einstellungen gespeichert. + + Fehler: Kein App-Benutzer ausgewählt. + Benutzereingabe verarbeitet. + + Vorgang erfolgreich. + Vorgang fehlgeschlagen. Bitte versuche es erneut. + das Gerät + Unbekannte Waage + Diese Waage wurde nicht gekoppelt!\n\nHalten Sie die Taste an der Unterseite der Waage gedrückt, um sie in den Kopplungsmodus zu versetzen, und verbinden Sie sich dann erneut, um das Gerätepasswort abzurufen. Kopplung erfolgreich!\n\nVerbinden Sie sich erneut, um Messdaten abzurufen. diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index b97c08e2..4a8cc112 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -383,6 +383,27 @@ Entire database has been deleted. Error deleting entire database. + + Bluetooth permissions are required to scan for devices. + Bluetooth is disabled. Please enable it to scan for devices. + + Bluetooth permissions are required to connect to %1$s. + Bluetooth permissions are required to connect to the device. + Bluetooth is disabled. Please enable it to connect to %1$s. + Bluetooth is disabled. Please enable it to connect to the device. + + %1$s saved as preferred scale. + Saved scale \'%1$s\' is no longer supported. + No Bluetooth scale saved in settings. + + Error: No app user selected. + User input processed. + + Operation successful. + Operation failed. Please try again. + the device + Unknown Scale + This scale has not been paired!\n\nHold the button on the bottom of the scale to switch it to pairing mode, and then reconnect to retrieve the device password. Pairing succeeded!\n\nReconnect to retrieve measurement data.