Skip to content
Draft
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 @@ -13,6 +13,8 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Icon
import android.media.AudioManager
import android.media.MediaMetadataRetriever
Expand Down Expand Up @@ -40,9 +42,17 @@ import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.getSystemService
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.scale
import androidx.core.net.toUri
import androidx.core.text.isDigitsOnly
import com.google.android.material.color.DynamicColors
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.backgroundColorRes
import com.mikepenz.iconics.utils.paddingDp
import com.mikepenz.iconics.utils.roundedCornersDp
import com.mikepenz.iconics.utils.sizeDp
import com.mikepenz.iconics.utils.toAndroidIconCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.authenticator.Authenticator
Expand All @@ -63,6 +73,7 @@ import io.homeassistant.companion.android.common.notifications.handleDeleteInten
import io.homeassistant.companion.android.common.notifications.handleSmallIcon
import io.homeassistant.companion.android.common.notifications.handleText
import io.homeassistant.companion.android.common.notifications.parseColor
import io.homeassistant.companion.android.common.notifications.parseFlattenedList
import io.homeassistant.companion.android.common.notifications.parseVibrationPattern
import io.homeassistant.companion.android.common.notifications.prepareText
import io.homeassistant.companion.android.common.util.cancelGroupIfNeeded
Expand Down Expand Up @@ -146,6 +157,18 @@ class MessagingManager @Inject constructor(
const val PROGRESS = "progress"
const val PROGRESS_MAX = "progress_max"
const val PROGRESS_INDETERMINATE = "progress_indeterminate"
const val PROGRESS_SEGMENTS = "progress_segments"
const val PROGRESS_SEGMENTS_LENGTH = "length"
const val PROGRESS_SEGMENTS_COLOR = "color"
const val PROGRESS_POINTS = "progress_points"
const val PROGRESS_POINTS_POSITION = "position"
const val PROGRESS_POINTS_COLOR = "color"
const val PROGRESS_TRACKER_ICON = "progress_tracker_icon"
const val PROGRESS_TRACKER_COLOR = "progress_tracker_color"
const val PROGRESS_START_ICON = "progress_start_icon"
const val PROGRESS_START_COLOR = "progress_start_color"
const val PROGRESS_END_ICON = "progress_end_icon"
const val PROGRESS_END_COLOR = "progress_end_color"
const val LIVE_UPDATE = "live_update"
const val CRITICAL_TEXT = "critical_text"
const val CAR_UI = "car_ui"
Expand Down Expand Up @@ -1075,6 +1098,8 @@ class MessagingManager @Inject constructor(

handleProgress(notificationBuilder, data)

handleProgressStyle(notificationBuilder, data)

handleLive(notificationBuilder, data)

val useCarNotification = handleCarUiVisible(context, notificationBuilder, data)
Expand Down Expand Up @@ -1151,6 +1176,132 @@ class MessagingManager @Inject constructor(
}
}

private fun handleProgressStyle(builder: NotificationCompat.Builder, data: Map<String, String>) {
Comment thread
TimoPtr marked this conversation as resolved.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val progressStyle = NotificationCompat.ProgressStyle().setStyledByProgress(true)
.setProgress(data[PROGRESS]?.toIntOrNull() ?: -1)

val dynamicColorContext = DynamicColors.wrapContextIfAvailable(context)
var accentColor = commonR.color.colorAccent
dynamicColorContext.withStyledAttributes(null, intArrayOf(android.R.attr.colorAccent)) {
accentColor = getResourceId(0, accentColor)
}
Comment thread
inukiwi marked this conversation as resolved.

var useProgressStyle = false
if (applyProgressSegments(progressStyle, accentColor, data)) useProgressStyle = true
if (applyProgressPoints(progressStyle, accentColor, data)) useProgressStyle = true
if (applyProgressStartIcon(progressStyle, accentColor, data)) useProgressStyle = true
if (applyProgressEndIcon(progressStyle, accentColor, data)) useProgressStyle = true
if (applyProgressTrackerIcon(progressStyle, accentColor, data)) useProgressStyle = true
if (useProgressStyle) {
builder.setStyle(progressStyle)
}
}

}

private fun applyProgressSegments(style: NotificationCompat.ProgressStyle, accentColor: Int, data: Map<String, String>): Boolean {
val segmentsData = data[PROGRESS_SEGMENTS] ?: ""
if (segmentsData.isNotBlank())
{
val segments = parseFlattenedList(segmentsData)
for (segment in segments) {
val length = segment[PROGRESS_SEGMENTS_LENGTH]?.toIntOrNull() ?: 0
val color = segment[PROGRESS_SEGMENTS_COLOR]
val progressSegment = NotificationCompat.ProgressStyle.Segment(length)
if (color != null) {
progressSegment.setColor(parseColor(context, color, accentColor))
}
style.addProgressSegment(progressSegment)
}
if (segments.isNotEmpty()) {
return true
}
}
return false
}

private fun applyProgressPoints(style: NotificationCompat.ProgressStyle, accentColor: Int, data: Map<String, String>): Boolean {
val pointsData = data[PROGRESS_POINTS] ?: ""
if (pointsData.isNotBlank()) {
val points = parseFlattenedList(pointsData)
for (point in points) {
val position = point[PROGRESS_POINTS_POSITION]?.toIntOrNull() ?: 0
val color = point[PROGRESS_POINTS_COLOR]
val progressPoint = NotificationCompat.ProgressStyle.Point(position)
if (color != null) {
progressPoint.setColor(parseColor(context, color, accentColor))
}
style.addProgressPoint(progressPoint)
}
if (points.isNotEmpty()) {
return true
}
}
return false
}

private fun applyProgressTrackerIcon(style: NotificationCompat.ProgressStyle, accentColor: Int, data: Map<String, String>): Boolean {
val progressTrackerIcon = data[PROGRESS_TRACKER_ICON] ?: ""
val progressTrackerColor =
parseColor(context, data[PROGRESS_TRACKER_COLOR] ?: "", commonR.color.colorPrimary)
if (progressTrackerIcon.startsWith("mdi:") && progressTrackerIcon.substringAfter("mdi:").isNotBlank()) {
val iconName = progressTrackerIcon.split(":")[1]
Comment on lines +1248 to +1249
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)

val iconDrawable = IconicsDrawable(context, "cmd-$iconName").apply {
sizeDp = 20
colorFilter = PorterDuffColorFilter(progressTrackerColor, PorterDuff.Mode.SRC_IN)
backgroundColorRes = accentColor
roundedCornersDp = 10
paddingDp = 4
}
Comment thread
inukiwi marked this conversation as resolved.
Comment on lines +1251 to +1256
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 (iconDrawable.icon != null) {
style.setProgressTrackerIcon(
iconDrawable.toAndroidIconCompat(),
)
return true
}
}
return false
}

private fun applyProgressStartIcon(style: NotificationCompat.ProgressStyle, accentColor: Int, data: Map<String, String>): Boolean {
val progressStartIcon = data[PROGRESS_START_ICON] ?: ""
val progressStartColor = parseColor(context, data[PROGRESS_START_COLOR] ?: "", accentColor)
if (progressStartIcon.startsWith("mdi:") && progressStartIcon.substringAfter("mdi:").isNotBlank()) {
val iconName = progressStartIcon.split(":")[1]
Comment on lines +1270 to +1271
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

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

colorFilter = PorterDuffColorFilter(progressStartColor, PorterDuff.Mode.SRC_IN)
}
if (iconDrawable.icon != null) {
style.setProgressStartIcon(
iconDrawable.toAndroidIconCompat(),
)
return true
}
}
return false
}

private fun applyProgressEndIcon(style: NotificationCompat.ProgressStyle, accentColor: Int, data: Map<String, String>): Boolean {
val progressEndIcon = data[PROGRESS_END_ICON] ?: ""
val progressEndColor = parseColor(context, data[PROGRESS_END_COLOR] ?: "", accentColor)
if (progressEndIcon.startsWith("mdi:") && progressEndIcon.substringAfter("mdi:").isNotBlank()) {
val iconName = progressEndIcon.split(":")[1]
Comment on lines +1289 to +1290
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

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

colorFilter = PorterDuffColorFilter(progressEndColor, PorterDuff.Mode.SRC_IN)
}
if (iconDrawable.icon != null) {
style.setProgressEndIcon(
iconDrawable.toAndroidIconCompat(),
)
return true
}
}
return false
}

private fun handleLive(builder: NotificationCompat.Builder, data: Map<String, String>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val liveUpdate = data[LIVE_UPDATE]?.toBoolean() ?: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,24 @@ fun handleDeleteIntent(
)
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.

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) {
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?

Timber.tag(NotificationData.TAG).e(e, "Unable to parse flattened list")
return emptyList()
Comment on lines +295 to +311
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.

}
}
Loading