Skip to content
13 changes: 13 additions & 0 deletions custom_components/universal_notifier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,19 @@ async def async_send_notification(call: ServiceCall):
final_msg = full_spoken_text
final_title = None
text_content_for_duration = final_msg
elif srv_domain == "notify" and srv_name == "send_message":
device_type = "apple" if is_apple_device(hass, dynamic_entities) else "android"
final_msg, final_title = apply_mobile_notify_text_formatting(
message=target_raw_message,
title=final_title,
device_type=device_type,
name=raw_name,
time_str=raw_time_str,
greeting=current_greeting,
parse_mode=parse_mode,
use_bold_prefix=use_bold_prefix,
skip_assistant_name=skip_assistant_name,
)
else:
clean_name = sanitize_text_visual(raw_name, parse_mode)
clean_time = sanitize_text_visual(raw_time_str, parse_mode)
Expand Down
97 changes: 97 additions & 0 deletions custom_components/universal_notifier/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,103 @@ def _protect(m):
return result


def strip_html(text: str) -> str:
"""Strip HTML tags from text, returning plain text."""
return re.sub(r'<[^>]+>', '', str(text)).strip()


def is_apple_device(hass, entity_ids) -> bool:
"""Return True if any target notify entity belongs to an Apple (iOS) device.

Falls back to False when the registries are not available (for example
during early startup or in tests with a mocked hass).
"""
if not entity_ids:
return False
if isinstance(entity_ids, str):
entity_ids = [entity_ids]
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
try:
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
except (KeyError, RuntimeError, AttributeError, TypeError):
return False
for eid in entity_ids:
if not isinstance(eid, str):
continue
ent = ent_reg.async_get(eid)
if ent and ent.device_id:
dev = dev_reg.async_get(ent.device_id)
if dev and (dev.manufacturer or "").lower() == "apple":
return True
return False


def apply_apple_notify_text_formatting(
message: str,
title: str | None,
) -> tuple:
"""iOS: plain text only, no HTML tags, no HA prefix, no greeting."""
return strip_html(str(message)), strip_html(str(title)) if title else title


def apply_android_notify_text_formatting(
message: str,
title: str | None,
name: str = "",
time_str: str = "",
greeting: str = "",
parse_mode: str | None = None,
use_bold_prefix: bool = True,
skip_assistant_name: bool = False,
) -> tuple:
"""Android: HTML formatting with HA prefix [name - time] and greeting."""
clean_name = sanitize_text_visual(name, parse_mode)
clean_time = sanitize_text_visual(time_str, parse_mode)
clean_msg = sanitize_text_visual(str(message), parse_mode)
clean_greet = sanitize_text_visual(greeting, parse_mode)
clean_orig_title = sanitize_text_visual(title, parse_mode) if title else None
if use_bold_prefix:
clean_name = apply_formatting(clean_name, parse_mode, "bold")
clean_time = apply_formatting(clean_time, parse_mode, "bold")
clean_orig_title = apply_formatting(clean_orig_title, parse_mode, "bold")
prefix_parts = []
if clean_name and not skip_assistant_name:
prefix_parts.append(clean_name)
if clean_time:
prefix_parts.append(clean_time)
clean_prefix = f"[{' - '.join(prefix_parts)}]" if prefix_parts else ""
greeting_part = f"{clean_greet}. " if clean_greet else ""
if clean_orig_title:
final_title = f"{clean_prefix} {clean_orig_title}" if clean_prefix else clean_orig_title
final_msg = f"{greeting_part}{clean_msg}"
else:
final_title = None
final_msg = f"{clean_prefix} {greeting_part}{clean_msg}" if clean_prefix else f"{greeting_part}{clean_msg}"
return final_msg, final_title


def apply_mobile_notify_text_formatting(
message: str,
title: str | None,
device_type: str,
name: str = "",
time_str: str = "",
greeting: str = "",
parse_mode: str | None = None,
use_bold_prefix: bool = True,
skip_assistant_name: bool = False,
) -> tuple:
"""Dispatch to iOS or Android formatting based on device_type ('apple' | 'android')."""
if device_type == "apple":
return apply_apple_notify_text_formatting(message, title)
return apply_android_notify_text_formatting(
message, title, name, time_str, greeting,
parse_mode, use_bold_prefix, skip_assistant_name,
)


def normalize_parse_mode(parse_mode: str, srv_domain: str) -> str | None:
"""Normalizza parse_mode per il dominio di servizio specifico."""
if not parse_mode:
Expand Down
128 changes: 120 additions & 8 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,11 @@

import pytest

from custom_components.universal_notifier.utils import (apply_formatting,
clean_text_for_tts,
escape_markdownv2,
estimate_tts_duration,
get_current_slot_info,
is_time_in_range,
normalize_parse_mode,
sanitize_text_visual)
from custom_components.universal_notifier.utils import (
apply_formatting, apply_mobile_notify_text_formatting, clean_text_for_tts,
escape_markdownv2, estimate_tts_duration, get_current_slot_info,
is_apple_device, is_time_in_range, normalize_parse_mode,
sanitize_text_visual, strip_html)

# ============================================================================
# estimate_tts_duration
Expand Down Expand Up @@ -341,3 +338,118 @@ def test_none_returns_none(self):

def test_empty_returns_none(self):
assert normalize_parse_mode("", "telegram_bot") is None


# ============================================================================
# strip_html
# ============================================================================

class TestStripHTML:
def test_removes_tags(self):
assert strip_html("<b>Bold</b> text") == "Bold text"

def test_removes_nested_tags(self):
assert strip_html("<b><i>Hi</i></b>") == "Hi"

def test_plain_text_unchanged(self):
assert strip_html("no tags here") == "no tags here"

def test_strips_surrounding_whitespace(self):
assert strip_html(" <b>x</b> ") == "x"

def test_non_string_input(self):
assert strip_html(123) == "123" # type: ignore[arg-type]


# ============================================================================
# is_apple_device
# ============================================================================

class TestIsAppleDevice:
def test_empty_entities_returns_false(self):
assert is_apple_device(object(), []) is False

def test_none_entities_returns_false(self):
assert is_apple_device(object(), None) is False

def test_missing_registries_returns_false(self):
"""A hass without registries must not raise, just report non-Apple."""
assert is_apple_device(object(), ["notify.mobile_app_phone"]) is False


# ============================================================================
# apply_mobile_notify_text_formatting
# ============================================================================

class TestApplyMobileNotifyTextFormatting:
def test_apple_strips_html_and_prefix(self):
msg, title = apply_mobile_notify_text_formatting(
message="<b>Hello</b>",
title="<i>Casa</i>",
device_type="apple",
name="Assistant",
time_str="10:00",
greeting="Good morning",
parse_mode="html",
)
assert msg == "Hello"
assert title == "Casa"

def test_apple_without_title(self):
msg, title = apply_mobile_notify_text_formatting(
message="<b>Hello</b>", title=None, device_type="apple"
)
assert msg == "Hello"
assert title is None

def test_android_keeps_prefix_and_greeting(self):
msg, title = apply_mobile_notify_text_formatting(
message="Hello",
title="Casa",
device_type="android",
name="Assistant",
time_str="10:00",
greeting="Good morning",
parse_mode="html",
)
assert "Good morning" in msg
assert "Hello" in msg
assert "Assistant" in title
assert "Casa" in title

def test_android_without_title_puts_prefix_in_message(self):
msg, title = apply_mobile_notify_text_formatting(
message="Hello",
title=None,
device_type="android",
name="Assistant",
time_str="10:00",
parse_mode="html",
)
assert "Assistant" in msg
assert "10:00" in msg
assert title is None

def test_android_skip_assistant_name(self):
msg, _ = apply_mobile_notify_text_formatting(
message="Hello",
title=None,
device_type="android",
name="Assistant",
time_str="10:00",
parse_mode="html",
skip_assistant_name=True,
)
assert "Assistant" not in msg
assert "10:00" in msg

def test_unknown_device_type_falls_back_to_android(self):
msg, _ = apply_mobile_notify_text_formatting(
message="Hello",
title=None,
device_type="whatever",
name="Assistant",
time_str="10:00",
parse_mode="html",
)
assert "Assistant" in msg
Loading