Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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<VersionWithAssets?> = MutableStateFlow(null)
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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<Map<String, WebsocketClient>>(emptyMap())
Expand Down Expand Up @@ -195,8 +200,9 @@ 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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading