Conversation
app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt
Show resolved
Hide resolved
There was a problem hiding this comment.
Pull request overview
Adds support for Android 16+ “progress-centric” notification customization in the Android Companion App notification pipeline, enabling richer progress rendering (segments/points and custom icons) when notifications are received from Home Assistant.
Changes:
- Added parsing helper (
parseFlattenedList) to interpret flattened list payloads (e.g., from WebSocket-flattened notification data) - Added Android 16+
NotificationCompat.ProgressStylehandling for segments, points, and tracker/start/end icons - Introduced new notification data keys for progress customization (
progress_segments,progress_points,progress_*_icon,progress_*_color)
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationFunctions.kt | Adds helper to parse flattened list strings into structured key/value maps for progress style inputs |
| app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt | Applies Android 16+ progress-centric ProgressStyle based on new notification payload keys |
You can also share your feedback on Copilot code review. Take the survey.
app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt
Show resolved
Hide resolved
app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt
Show resolved
Hide resolved
| try { | ||
| val list: List<Map<String, String>> = flattenedList | ||
| .trim() | ||
| .removeSurrounding("[", "]") | ||
| .split("}, {") | ||
| .map { segment -> | ||
| segment.trim().removePrefix("{").removeSuffix("}") | ||
| .split(",") | ||
| .associate { pair -> | ||
| val (key, value) = pair.split("=") | ||
| key.trim() to value.trim() | ||
| } | ||
| } | ||
| return list | ||
| } catch (e: Exception) { | ||
| Timber.tag(NotificationData.TAG).e(e, "Unable to parse flattened list") | ||
| return emptyList() |
There was a problem hiding this comment.
parseFlattenedList will throw (and log an error) for valid inputs like an empty list ("[]"), because after removeSurrounding the string becomes blank and the code still tries to parse key/value pairs. It is also brittle for minor formatting differences (e.g., missing spaces between elements) and for values containing =.
Consider handling blank/empty lists explicitly (return empty without logging), splitting map entries more defensively (e.g., split("=", limit = 2)), and using a separator regex like "}\s*,\s*{" so spacing differences do not break parsing.
| try { | |
| val list: List<Map<String, String>> = flattenedList | |
| .trim() | |
| .removeSurrounding("[", "]") | |
| .split("}, {") | |
| .map { segment -> | |
| segment.trim().removePrefix("{").removeSuffix("}") | |
| .split(",") | |
| .associate { pair -> | |
| val (key, value) = pair.split("=") | |
| key.trim() to value.trim() | |
| } | |
| } | |
| return list | |
| } catch (e: Exception) { | |
| Timber.tag(NotificationData.TAG).e(e, "Unable to parse flattened list") | |
| return emptyList() | |
| val content = flattenedList | |
| .trim() | |
| .removeSurrounding("[", "]") | |
| .trim() | |
| if (content.isBlank()) { | |
| // Treat an empty or whitespace-only list as a valid empty result | |
| return emptyList() | |
| } | |
| return try { | |
| val segmentSeparator = Regex("\\}\\s*,\\s*\\{") | |
| content | |
| .split(segmentSeparator) | |
| .map { segment -> | |
| val normalizedSegment = segment | |
| .trim() | |
| .removePrefix("{") | |
| .removeSuffix("}") | |
| .trim() | |
| if (normalizedSegment.isBlank()) { | |
| emptyMap() | |
| } else { | |
| normalizedSegment | |
| .split(Regex("\\s*,\\s*")) | |
| .filter { it.isNotBlank() } | |
| .associate { pair -> | |
| val parts = pair.split("=", limit = 2) | |
| val key = parts.getOrNull(0)?.trim().orEmpty() | |
| val value = parts.getOrNull(1)?.trim().orEmpty() | |
| key to value | |
| } | |
| } | |
| } | |
| } catch (e: Exception) { | |
| Timber.tag(NotificationData.TAG).e(e, "Unable to parse flattened list") | |
| emptyList() |
There was a problem hiding this comment.
I opted for a minimal method here that only handles valid lists with a depth of 1 and defaults to an empty list.
If we want this method to be used in the future for other purposes I can also rewrite it into a recursive method that handles all nested objects/lists
There was a problem hiding this comment.
If you make it public, you need to write down the limitations.
| builder.setDeleteIntent(deletePendingIntent) | ||
| } | ||
|
|
||
| fun parseFlattenedList(flattenedList: String): List<Map<String, String>> { |
There was a problem hiding this comment.
This function is quite specific no? It is a public function it's quite bad to expose it to the rest of the app, what would you think if you were proposed this function by the IDE without knowing the internal and where it can be used?
I would also this to be documented with examples.
| } | ||
| } | ||
| return list | ||
| } catch (e: Exception) { |
There was a problem hiding this comment.
The error catching is too broad can you identify what can throw and only catch this?
| sizeDp = 20 | ||
| colorFilter = PorterDuffColorFilter(progressTrackerColor, PorterDuff.Mode.SRC_IN) | ||
| backgroundColorRes = accentColor | ||
| roundedCornersDp = 10 | ||
| paddingDp = 4 | ||
| } |
There was a problem hiding this comment.
I would like some docs about the choice of these magic numbers.
| if (progressStartIcon.startsWith("mdi:") && progressStartIcon.substringAfter("mdi:").isNotBlank()) { | ||
| val iconName = progressStartIcon.split(":")[1] | ||
| val iconDrawable = IconicsDrawable(context, "cmd-$iconName").apply { | ||
| sizeDp = 20 |
| if (progressEndIcon.startsWith("mdi:") && progressEndIcon.substringAfter("mdi:").isNotBlank()) { | ||
| val iconName = progressEndIcon.split(":")[1] | ||
| val iconDrawable = IconicsDrawable(context, "cmd-$iconName").apply { | ||
| sizeDp = 20 |
| if (progressTrackerIcon.startsWith("mdi:") && progressTrackerIcon.substringAfter("mdi:").isNotBlank()) { | ||
| val iconName = progressTrackerIcon.split(":")[1] |
There was a problem hiding this comment.
Just to be on the safe side we might want to verify the size while doing the split.
There was a problem hiding this comment.
isNotBlank() already suggests the size is at least 1 non-whitespace character though, and user input means incorrect icon names need to be handled anyway (below with != null after trying)
| if (progressStartIcon.startsWith("mdi:") && progressStartIcon.substringAfter("mdi:").isNotBlank()) { | ||
| val iconName = progressStartIcon.split(":")[1] |
There was a problem hiding this comment.
same and could actually be wrap into a small helper
| if (progressEndIcon.startsWith("mdi:") && progressEndIcon.substringAfter("mdi:").isNotBlank()) { | ||
| val iconName = progressEndIcon.split(":")[1] |
Summary
This feature adds support for progress bar customization on Android 16.0+ devices, as shown here.
Checklist
Screenshots
Link to pull request in documentation repositories
User Documentation: home-assistant/companion.home-assistant#1297
Any other notes