Skip to content

Progress bar customization#6527

Draft
inukiwi wants to merge 6 commits intohome-assistant:mainfrom
inukiwi:progress_style_notifications
Draft

Progress bar customization#6527
inukiwi wants to merge 6 commits intohome-assistant:mainfrom
inukiwi:progress_style_notifications

Conversation

@inukiwi
Copy link
Copy Markdown
Contributor

@inukiwi inukiwi commented Mar 3, 2026

Summary

This feature adds support for progress bar customization on Android 16.0+ devices, as shown here.

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

Screenshots

image

Link to pull request in documentation repositories

User Documentation: home-assistant/companion.home-assistant#1297

Any other notes

@inukiwi inukiwi marked this pull request as ready for review March 6, 2026 11:59
Copilot AI review requested due to automatic review settings March 6, 2026 11:59
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ProgressStyle handling 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.

Comment on lines +295 to +311
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()
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you make it public, you need to write down the limitations.

builder.setDeleteIntent(deletePendingIntent)
}

fun parseFlattenedList(flattenedList: String): List<Map<String, String>> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error catching is too broad can you identify what can throw and only catch this?

Comment on lines +1251 to +1256
sizeDp = 20
colorFilter = PorterDuffColorFilter(progressTrackerColor, PorterDuff.Mode.SRC_IN)
backgroundColorRes = accentColor
roundedCornersDp = 10
paddingDp = 4
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here or even a const

if (progressEndIcon.startsWith("mdi:") && progressEndIcon.substringAfter("mdi:").isNotBlank()) {
val iconName = progressEndIcon.split(":")[1]
val iconDrawable = IconicsDrawable(context, "cmd-$iconName").apply {
sizeDp = 20
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here or even a const

Comment on lines +1248 to +1249
if (progressTrackerIcon.startsWith("mdi:") && progressTrackerIcon.substringAfter("mdi:").isNotBlank()) {
val iconName = progressTrackerIcon.split(":")[1]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be on the safe side we might want to verify the size while doing the split.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +1270 to +1271
if (progressStartIcon.startsWith("mdi:") && progressStartIcon.substringAfter("mdi:").isNotBlank()) {
val iconName = progressStartIcon.split(":")[1]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same and could actually be wrap into a small helper

Comment on lines +1289 to +1290
if (progressEndIcon.startsWith("mdi:") && progressEndIcon.substringAfter("mdi:").isNotBlank()) {
val iconName = progressEndIcon.split(":")[1]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

@TimoPtr TimoPtr marked this pull request as draft March 23, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants