From 53d05505c66de12072d62f08a09dc79681fd61ab Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 15 Aug 2025 08:07:41 +0200 Subject: [PATCH] Added some documentation to BluetoothViewModel for clarity --- .../ui/screen/bluetooth/BluetoothViewModel.kt | 243 +++++++++++++++--- 1 file changed, 208 insertions(+), 35 deletions(-) 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 0c4668a0..84e29a03 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 @@ -53,12 +53,33 @@ import java.util.Date * Represents the various states of a Bluetooth connection. */ enum class ConnectionStatus { - NONE, IDLE, DISCONNECTED, CONNECTING, CONNECTED, DISCONNECTING, FAILED + /** 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 } /** - * ViewModel for Bluetooth interactions: scanning, connection, data handling. - * Coordinates with [BluetoothScannerManager] and [BluetoothConnectionManager]. + * 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). */ class BluetoothViewModel( private val application: Application, // Used for context and string resources @@ -67,19 +88,25 @@ class BluetoothViewModel( private companion object { const val TAG = "BluetoothViewModel" - const val SCAN_DURATION_MS = 20000L + const val SCAN_DURATION_MS = 20000L // Default scan duration: 20 seconds } + // 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 + private var currentBtScaleUser: ScaleUser? = null // Derived from currentAppUser for Bluetooth operations 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, @@ -96,43 +123,68 @@ class BluetoothViewModel( } ) + // --- 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) --- + /** Emits a [BluetoothEvent.UserInteractionRequired] when the connected scale needs user input (e.g., user selection). Null otherwise. */ 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) { + if (currentAppUserId != userDetails.id) { // Only update if the user actually changed. LogManager.d(TAG, "User changed via UserSettingsRepository: ${userDetails.name}. Updating context.") updateCurrentUserContext(userDetails) } @@ -149,6 +201,10 @@ 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 @@ -156,6 +212,9 @@ 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 @@ -163,20 +222,32 @@ 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 { id = appUser.id userName = appUser.name - birthday = Date(appUser.birthDate) - bodyHeight = appUser.heightCm ?: 0f + birthday = Date(appUser.birthDate) // Ensure birthDate is in millis + bodyHeight = appUser.heightCm ?: 0f // Default to 0f if height is null gender = appUser.gender } } - @SuppressLint("MissingPermission") + // --- 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. fun requestStartDeviceScan() { LogManager.i(TAG, "User requested to start device scan.") - refreshPermissionsStatus() + refreshPermissionsStatus() // Ensure permission state is up-to-date. if (!permissionsGranted.value) { LogManager.w(TAG, "Scan request denied: Bluetooth permissions missing.") @@ -194,17 +265,29 @@ class BluetoothViewModel( ) return } - clearAllErrors() + clearAllErrors() // Clear previous scan/connection errors. 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.") bluetoothScannerManager.stopScan() } - @SuppressLint("MissingPermission") + // --- 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") @@ -212,9 +295,13 @@ 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/sets error for ConnectionManager. return } @@ -222,7 +309,13 @@ class BluetoothViewModel( bluetoothConnectionManager.connectToDevice(deviceInfo) } - @SuppressLint("MissingPermission") + + /** + * Attempts to connect to the saved preferred Bluetooth scale. + * Retrieves device info from [userSettingsRepository] and then delegates + * to [BluetoothConnectionManager]. It also re-evaluates device support via [ScaleFactory]. + */ + @SuppressLint("MissingPermission") // Permissions are checked by validateConnectionPrerequisites. fun connectToSavedDevice() { viewModelScope.launch { val address = savedScaleAddress.value @@ -232,24 +325,38 @@ 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 needed for scan to fully stop. } if (!validateConnectionPrerequisites(name, isManualConnect = false)) { + // For automatic attempts, validateConnectionPrerequisites shows a Snackbar for errors. 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 or if the device firmware changed. LogManager.d(TAG, "Re-evaluating support for saved device '$name' ($address) using ScaleFactory.") + + // Create a temporary ScannedDeviceInfo object for the saved device. + // RSSI, serviceUuids, manufacturerData are not critical here as ScaleFactory + // primarily uses name/address for matching against known handlers. val deviceInfoForConnect = ScannedDeviceInfo( - name = name, address = address, rssi = 0, serviceUuids = emptyList(), - manufacturerData = null, isSupported = false, determinedHandlerDisplayName = null + name = name, + address = address, + rssi = 0, + serviceUuids = emptyList(), + manufacturerData = null, + isSupported = false, // This will be determined by getSupportingHandlerInfo + determinedHandlerDisplayName = null // This will also be determined ) + 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. Connection aborted.") + LogManager.w(TAG, "Saved device '$name' ($address) is currently not supported by ScaleFactory. Connection aborted.") sharedViewModel.showSnackbar( application.getString(R.string.bt_snackbar_saved_scale_no_longer_supported, name), SnackbarDuration.Long @@ -270,22 +377,29 @@ class BluetoothViewModel( /** * Validates common prerequisites for initiating a Bluetooth connection. + * Checks for permissions and Bluetooth enabled status. + * + * @param deviceNameForMessage 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(deviceNameForMessage: String?, isManualConnect: Boolean): Boolean { - refreshPermissionsStatus() + refreshPermissionsStatus() // Always get the latest permission status. - val devicePlaceholder = application.getString(R.string.device_placeholder_name) // "the device" + val devicePlaceholder = application.getString(R.string.device_placeholder_name) // Default like "the device" if (!permissionsGranted.value) { val errorMsg = application.getString( R.string.bt_snackbar_permissions_required_to_connect, deviceNameForMessage ?: devicePlaceholder ) - LogManager.w(TAG, "Connection prerequisite failed: $errorMsg") + LogManager.w(TAG, "Connection prerequisite failed for '${deviceNameForMessage ?: "unknown device"}': Bluetooth permissions missing.") 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. sharedViewModel.showSnackbar(errorMsg, SnackbarDuration.Long) } return false @@ -295,7 +409,7 @@ class BluetoothViewModel( R.string.bt_snackbar_bluetooth_disabled_to_connect, deviceNameForMessage ?: devicePlaceholder ) - LogManager.w(TAG, "Connection prerequisite failed: $errorMsg") + LogManager.w(TAG, "Connection prerequisite failed for '${deviceNameForMessage ?: "unknown device"}': Bluetooth is disabled.") if (isManualConnect) { bluetoothConnectionManager.setExternalConnectionError(errorMsg) } else { @@ -306,45 +420,64 @@ class BluetoothViewModel( 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() bluetoothConnectionManager.clearConnectionError() } + /** + * Clears a pending user interaction event from the [BluetoothConnectionManager]. + * This is typically called after the user has responded or wants to dismiss the interaction. + */ fun clearPendingUserInteraction() { LogManager.d(TAG, "Requesting to clear pending user interaction event.") bluetoothConnectionManager.clearUserInteractionEvent() } + /** + * Processes user-provided feedback for a pending Bluetooth user interaction event. + * This is used, for example, when a scale requires the user to be selected from a list. + * + * @param interactionType The type of interaction that occurred (e.g., USER_SELECTION). + * @param feedbackData The data provided by the user (e.g., the selected user's index or ID). + */ fun processUserInteraction(interactionType: UserInteractionType, feedbackData: Any) { viewModelScope.launch { - val localCurrentAppUser = currentAppUser // Use local copy for thread safety check + val localCurrentAppUser = currentAppUser // Use local copy for thread-safety in coroutine if (localCurrentAppUser == null || localCurrentAppUser.id == 0) { + LogManager.w(TAG, "User interaction processing aborted: No current app user selected.") sharedViewModel.showSnackbar( application.getString(R.string.bt_snackbar_error_no_app_user_selected), - SnackbarDuration.Short // Assuming short duration, adjust if needed + SnackbarDuration.Short // Or .Long if more prominent message needed ) - bluetoothConnectionManager.clearUserInteractionEvent() + bluetoothConnectionManager.clearUserInteractionEvent() // Clear the prompt as it cannot be handled return@launch } - val appUserId = localCurrentAppUser.id + val appUserId = localCurrentAppUser.id // This is the app's internal user ID - // 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()) + LogManager.d(TAG, "Processing user interaction: Type=$interactionType, AppUserID=$appUserId, Data=$feedbackData") bluetoothConnectionManager.provideUserInteractionFeedback( interactionType, - appUserId, + appUserId, // Pass the app's user ID feedbackData, - uiHandler // Pass if strictly needed by the manager for immediate UI thread tasks + uiHandler // Pass handler if ConnectionManager needs it for specific tasks ) sharedViewModel.showSnackbar( @@ -356,9 +489,15 @@ class BluetoothViewModel( } } + // --- 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 ?: application.getString(R.string.unknown_scale_name) // Default name from resources + val nameToSave = device.name ?: application.getString(R.string.unknown_scale_name) // Provide a default name from resources if null. 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( @@ -368,17 +507,31 @@ class BluetoothViewModel( } } + // --- 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) { + // 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 } } + /** + * 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 + * (e.g., after user grants them in system settings). + */ fun refreshPermissionsStatus() { val currentStatus = checkInitialPermissions() if (_permissionsGranted.value != currentStatus) { @@ -387,12 +540,25 @@ 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? - return bluetoothManager?.adapter?.isEnabled ?: false + val isEnabled = bluetoothManager?.adapter?.isEnabled ?: false + if (!isEnabled) { + LogManager.w(TAG, "Bluetooth adapter is disabled.") // Log only if disabled for less verbose logging. + } + return isEnabled } - @SuppressLint("MissingPermission") + /** + * 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 internally. fun attemptAutoConnectToSavedScale() { viewModelScope.launch { val address = savedScaleAddress.value @@ -400,6 +566,7 @@ class BluetoothViewModel( if (address != null && name != null) { LogManager.i(TAG, "Attempting auto-connect to saved scale: '$name' ($address).") + // Check if already connected or in the process of connecting to the *same* saved device. if ((connectionStatus.value == ConnectionStatus.CONNECTED || connectionStatus.value == ConnectionStatus.CONNECTING) && connectedDeviceAddress.value == address ) { @@ -409,17 +576,23 @@ class BluetoothViewModel( 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" + // Optionally, show a non-blocking snackbar if desired, though auto-attempts are usually 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 like GATT connections or broadcast receivers). + */ override fun onCleared() { super.onCleared() LogManager.i(TAG, "BluetoothViewModel onCleared. Releasing resources from managers.") - bluetoothScannerManager.close() - bluetoothConnectionManager.close() + bluetoothScannerManager.close() // Tell scanner manager to clean up + bluetoothConnectionManager.close() // Tell connection manager to clean up LogManager.i(TAG, "BluetoothViewModel onCleared completed.") } }