1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-19 23:12:12 +02:00

Added some documentation to BluetoothViewModel for clarity

This commit is contained in:
oliexdev
2025-08-15 08:07:41 +02:00
parent 5bef95e613
commit 53d05505c6

View File

@@ -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<List<ScannedDeviceInfo>> = bluetoothScannerManager.scannedDevices
/** Emits `true` if a Bluetooth scan is currently active, `false` otherwise. */
val isScanning: StateFlow<Boolean> = bluetoothScannerManager.isScanning
/** Emits error messages related to the scanning process, or null if no error. */
val scanError: StateFlow<String?> = bluetoothScannerManager.scanError
// --- Connection State Flows (from BluetoothConnectionManager) ---
/** Emits the MAC address of the currently connected device, or null if not connected. */
val connectedDeviceAddress: StateFlow<String?> = bluetoothConnectionManager.connectedDeviceAddress
/** Emits the current [ConnectionStatus] of the Bluetooth device. */
val connectionStatus: StateFlow<ConnectionStatus> = bluetoothConnectionManager.connectionStatus
/** Emits connection-related error messages, or null if no error. */
val connectionError: StateFlow<String?> = 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<Boolean> = _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<String?> = 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<String?> = 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<BluetoothEvent.UserInteractionRequired?> =
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.")
}
}