From 156cff903a5fd41c75e1dfd2ae9ab414c3eac8c8 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Fri, 16 Jan 2026 02:36:28 -0500 Subject: [PATCH 1/2] refactor(widget): Centralize device name logic and improve widget updates This commit introduces a centralized function for determining a device's display name and refactors widget updates to be more robust and efficient. - A non-composable `getDeviceName` utility function has been created to provide a consistent device name (custom name, then original name, then a fallback) for use in widgets and other non-UI contexts. - The `WledWidgetConfigureActivity` now uses this new function when creating initial widget data. - Device name changes now trigger an immediate update on associated widgets, ensuring the new name is reflected without waiting for the next state refresh. - Deleting a device now correctly clears the data for all its associated widgets, putting them into a re-configurable error state. - WebSocket and API update logic in `WledWidgetManager` has been streamlined, now passing the full `DeviceWithState` or `Device` object instead of multiple parameters. This allows the manager to consistently use the new `getDeviceName` function for all updates. --- .../service/websocket/WebsocketClient.kt | 10 +-- .../deviceEdit/DeviceEditViewModel.kt | 11 ++- .../list/DeviceWebsocketListViewModel.kt | 6 ++ .../widget/WledWidgetConfigureActivity.kt | 3 +- .../widget/WledWidgetManager.kt | 74 +++++++++++++++---- .../widget/components/GetDeviceName.kt | 17 +++++ 6 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/ca/cgagnier/wlednativeandroid/widget/components/GetDeviceName.kt diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/websocket/WebsocketClient.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/websocket/WebsocketClient.kt index 43c14c6..c0f3deb 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/service/websocket/WebsocketClient.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/service/websocket/WebsocketClient.kt @@ -80,7 +80,7 @@ class WebsocketClient( // Ideally, this should probably not be done in the client directly coroutineScope.launch { saveDeviceIfChanged(deviceStateInfo) - updateWidgets(deviceStateInfo) + updateWidgets() } } else { Log.w(TAG, "Received a null message after parsing.") @@ -144,12 +144,10 @@ class WebsocketClient( /** * Updates any active widgets for this device with the latest state. */ - private suspend fun updateWidgets(deviceStateInfo: DeviceStateInfo) { - widgetManager.updateWidgetsFromDeviceState( + private suspend fun updateWidgets() { + widgetManager.updateWidgetsFromDeviceWithState( applicationContext, - deviceState.device.macAddress, - deviceState.device.address, - deviceStateInfo, + deviceState, ) } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt index b2f3af6..ad1ee2a 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/deviceEdit/DeviceEditViewModel.kt @@ -1,5 +1,6 @@ package ca.cgagnier.wlednativeandroid.ui.homeScreen.deviceEdit +import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -10,7 +11,9 @@ import ca.cgagnier.wlednativeandroid.repository.DeviceRepository import ca.cgagnier.wlednativeandroid.repository.VersionWithAssetsRepository import ca.cgagnier.wlednativeandroid.service.api.github.GithubApi import ca.cgagnier.wlednativeandroid.service.update.ReleaseService +import ca.cgagnier.wlednativeandroid.widget.WledWidgetManager import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,6 +27,8 @@ class DeviceEditViewModel @Inject constructor( private val repository: DeviceRepository, private val versionWithAssetsRepository: VersionWithAssetsRepository, private val githubApi: GithubApi, + private val widgetManager: WledWidgetManager, + @param:ApplicationContext private val applicationContext: Context, ) : ViewModel() { private var _updateDetailsVersion: MutableStateFlow = MutableStateFlow(null) @@ -40,14 +45,16 @@ class DeviceEditViewModel @Inject constructor( val isCheckingUpdates = _isCheckingUpdates.asStateFlow() fun updateCustomName(device: Device, name: String) = viewModelScope.launch(Dispatchers.IO) { - val isCustomName = name != "" val updatedDevice = device.copy( customName = name, ) - Log.d(TAG, "updateCustomName: $name, isCustom: $isCustomName") + Log.d(TAG, "updateCustomName: $name") repository.update(updatedDevice) + + // Update widgets to show the new name + widgetManager.updateWidgetNamesForDevice(applicationContext, updatedDevice) } fun updateDeviceHidden(device: Device, isHidden: Boolean) = viewModelScope.launch(Dispatchers.IO) { diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt index f61fd96..48b339c 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt @@ -1,5 +1,6 @@ package ca.cgagnier.wlednativeandroid.ui.homeScreen.list +import android.content.Context import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -13,7 +14,9 @@ import ca.cgagnier.wlednativeandroid.repository.UserPreferencesRepository import ca.cgagnier.wlednativeandroid.service.websocket.DeviceWithState import ca.cgagnier.wlednativeandroid.service.websocket.WebsocketClient import ca.cgagnier.wlednativeandroid.service.websocket.WebsocketClientFactory +import ca.cgagnier.wlednativeandroid.widget.WledWidgetManager import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -32,6 +35,8 @@ class DeviceWebsocketListViewModel @Inject constructor( userPreferencesRepository: UserPreferencesRepository, private val deviceRepository: DeviceRepository, private val websocketClientFactory: WebsocketClientFactory, + private val widgetManager: WledWidgetManager, + @ApplicationContext private val applicationContext: Context, ) : ViewModel(), DefaultLifecycleObserver { private val activeClients = MutableStateFlow>(emptyMap()) @@ -197,6 +202,7 @@ class DeviceWebsocketListViewModel @Inject constructor( fun deleteDevice(device: Device) { viewModelScope.launch { Log.d(TAG, "Deleting device ${device.originalName} - ${device.address}") + widgetManager.deleteWidgetsForDevice(applicationContext, device.macAddress) deviceRepository.delete(device) } } diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt index 3278db4..6374a0e 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetConfigureActivity.kt @@ -32,6 +32,7 @@ import ca.cgagnier.wlednativeandroid.model.Device import ca.cgagnier.wlednativeandroid.repository.DeviceRepository import ca.cgagnier.wlednativeandroid.ui.components.deviceName import ca.cgagnier.wlednativeandroid.ui.theme.WLEDNativeTheme +import ca.cgagnier.wlednativeandroid.widget.components.getDeviceName import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -87,7 +88,7 @@ class WledWidgetConfigureActivity : ComponentActivity() { val widgetData = WidgetStateData( macAddress = device.macAddress, address = device.address, - name = device.customName.ifBlank { device.originalName }, + name = getDeviceName(device, getString(R.string.default_device_name)), isOn = false, lastUpdated = System.currentTimeMillis(), ) diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt index 66e7b17..bce2614 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/WledWidgetManager.kt @@ -7,11 +7,14 @@ import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition -import ca.cgagnier.wlednativeandroid.model.wledapi.DeviceStateInfo +import ca.cgagnier.wlednativeandroid.R +import ca.cgagnier.wlednativeandroid.model.Device import ca.cgagnier.wlednativeandroid.model.wledapi.JsonPost import ca.cgagnier.wlednativeandroid.repository.DeviceRepository import ca.cgagnier.wlednativeandroid.service.api.DeviceApiFactory +import ca.cgagnier.wlednativeandroid.service.websocket.DeviceWithState import ca.cgagnier.wlednativeandroid.ui.theme.getColorFromDeviceState +import ca.cgagnier.wlednativeandroid.widget.components.getDeviceName import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import javax.inject.Inject @@ -31,30 +34,25 @@ class WledWidgetManager @Inject constructor( * (e.g., WebSocket). * * @param context Application context - * @param macAddress The MAC address of the device - * @param address The current network address of the device - * @param stateInfo The device state info received from WebSocket + * @param deviceWithState The device with its current state */ - suspend fun updateWidgetsFromDeviceState( - context: Context, - macAddress: String, - address: String, - stateInfo: DeviceStateInfo, - ) { + suspend fun updateWidgetsFromDeviceWithState(context: Context, deviceWithState: DeviceWithState) { + val stateInfo = deviceWithState.stateInfo.value ?: return + // Create a complete, authoritative state from scratch // This ensures all widgets for the device get the exact same state val newData = WidgetStateData( - macAddress = macAddress, - address = address, - name = stateInfo.info.name, + macAddress = deviceWithState.device.macAddress, + address = deviceWithState.device.address, + name = getDeviceName(deviceWithState.device, context.getString(R.string.default_device_name)), isOn = stateInfo.state.isOn ?: false, isOnline = true, color = getColorFromDeviceState(stateInfo.state), lastUpdated = System.currentTimeMillis(), ) - Log.d(TAG, "Updating widgets from websocket for MAC $macAddress") - updateWidgetsForMacAddress(context, macAddress, newData) + Log.d(TAG, "Updating widgets from websocket for MAC ${deviceWithState.device.macAddress}") + updateWidgetsForMacAddress(context, deviceWithState.device.macAddress, newData) } suspend fun updateAllWidgets(context: Context) { @@ -108,6 +106,7 @@ class WledWidgetManager @Inject constructor( response.body()?.let { body -> val newData = widgetData.copy( address = targetAddress, + name = getDeviceName(device, context.getString(R.string.default_device_name)), isOn = body.isOn ?: jsonPost.isOn ?: widgetData.isOn, color = getColorFromDeviceState(body), isOnline = true, @@ -136,6 +135,51 @@ class WledWidgetManager @Inject constructor( } } + /** + * Updates the name displayed on all widgets for a device. + * Call this when the device's customName or originalName changes. + */ + suspend fun updateWidgetNamesForDevice(context: Context, device: Device) { + val manager = GlanceAppWidgetManager(context) + val glanceIds = manager.getGlanceIds(WledWidget::class.java) + val displayName = getDeviceName(device, context.getString(R.string.default_device_name)) + + glanceIds.forEach { glanceId -> + val widgetState = getWidgetState(context, glanceId) ?: return@forEach + if (widgetState.macAddress == device.macAddress) { + Log.d(TAG, "Updating widget name for MAC ${device.macAddress} to $displayName") + val newData = widgetState.copy(name = displayName) + saveStateAndPush(context, glanceId, newData) + } + } + } + + /** + * Clears data for all widgets associated with a device. + * Call this when a device is deleted from the app. + * The widgets will show an error state prompting reconfiguration. + */ + suspend fun deleteWidgetsForDevice(context: Context, macAddress: String) { + val manager = GlanceAppWidgetManager(context) + val glanceIds = manager.getGlanceIds(WledWidget::class.java) + + glanceIds.forEach { glanceId -> + val widgetState = getWidgetState(context, glanceId) ?: return@forEach + if (widgetState.macAddress == macAddress) { + try { + // Clear state - widget will show error state prompting reconfiguration + updateAppWidgetState(context, glanceId) { prefs -> + prefs.remove(WIDGET_DATA_KEY) + } + WledWidget().update(context, glanceId) + Log.d(TAG, "Cleared widget data for deleted device: $macAddress") + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Log.e(TAG, "Error clearing widget for device $macAddress", e) + } + } + } + } + private suspend fun getWidgetState(context: Context, glanceId: GlanceId): WidgetStateData? { val prefs = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) val jsonString = prefs[WIDGET_DATA_KEY] ?: return null diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/components/GetDeviceName.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/components/GetDeviceName.kt new file mode 100644 index 0000000..7ef18c1 --- /dev/null +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/widget/components/GetDeviceName.kt @@ -0,0 +1,17 @@ +package ca.cgagnier.wlednativeandroid.widget.components + +import ca.cgagnier.wlednativeandroid.model.Device + +/** + * Returns the display name for a device following the hierarchy: + * 1. Custom name (if not blank) + * 2. Original name (if not blank) + * 3. Fallback name + * + * This is a non-Composable version of the [ca.cgagnier.wlednativeandroid.ui.components.deviceName] + * function that can be used outside of Compose context (e.g., in background services). + */ +fun getDeviceName(device: Device?, fallbackName: String): String = + device?.customName?.trim().takeIf { !it.isNullOrBlank() } + ?: device?.originalName?.trim().takeIf { !it.isNullOrBlank() } + ?: fallbackName From e59f50bfa821f91a821f9ffb9e5c2f8f2e3a8aa9 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Fri, 16 Jan 2026 02:48:04 -0500 Subject: [PATCH 2/2] feat: Move device deletion to I/O dispatcher The device deletion process, which includes database operations and widget management, is now explicitly moved to the I/O dispatcher. This prevents blocking the main thread during these operations. --- .../ui/homeScreen/list/DeviceWebsocketListViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt index 48b339c..6316aa7 100644 --- a/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt +++ b/app/src/main/java/ca/cgagnier/wlednativeandroid/ui/homeScreen/list/DeviceWebsocketListViewModel.kt @@ -200,7 +200,7 @@ class DeviceWebsocketListViewModel @Inject constructor( } fun deleteDevice(device: Device) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { Log.d(TAG, "Deleting device ${device.originalName} - ${device.address}") widgetManager.deleteWidgetsForDevice(applicationContext, device.macAddress) deviceRepository.delete(device)