fix(widget): Improve MiddleEllipsisText Unicode and surrogate handling

This commit is contained in:
cogwheel
2025-12-20 22:31:18 +05:30
parent cc97b7a886
commit 7adcf0d45c
2 changed files with 71 additions and 23 deletions

View File

@@ -343,20 +343,17 @@ class VoiceCallService {
// Set up periodic keep-alive to refresh wake lock (every 5 minutes) // Set up periodic keep-alive to refresh wake lock (every 5 minutes)
_keepAliveTimer?.cancel(); _keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic( _keepAliveTimer = Timer.periodic(const Duration(minutes: 5), (_) async {
const Duration(minutes: 5), final success = await BackgroundStreamingHandler.instance.keepAlive();
(_) async { if (!success) {
final success = await BackgroundStreamingHandler.instance.keepAlive(); // Keep-alive failed but don't stop the call - service may still work
if (!success) { developer.log(
// Keep-alive failed but don't stop the call - service may still work 'Voice call keep-alive failed',
developer.log( name: 'VoiceCallService',
'Voice call keep-alive failed', level: 900, // WARNING
name: 'VoiceCallService', );
level: 900, // WARNING }
); });
}
},
);
// Set up socket event listener for assistant responses // Set up socket event listener for assistant responses
_socketSubscription = _socketService.addChatEventHandler( _socketSubscription = _socketService.addChatEventHandler(

View File

@@ -2,6 +2,9 @@ import 'package:flutter/widgets.dart';
/// A single-line text widget that truncates the middle of long strings /// A single-line text widget that truncates the middle of long strings
/// with an ellipsis (e.g., "prefix…suffix") so both ends remain visible. /// with an ellipsis (e.g., "prefix…suffix") so both ends remain visible.
///
/// This widget handles Unicode text safely, including emojis and other
/// characters that span multiple UTF-16 code units (surrogate pairs).
class MiddleEllipsisText extends StatelessWidget { class MiddleEllipsisText extends StatelessWidget {
final String text; final String text;
final TextStyle? style; final TextStyle? style;
@@ -20,6 +23,9 @@ class MiddleEllipsisText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Sanitize text to remove any unpaired surrogates that could cause crashes.
final String safeText = _sanitizeUtf16(text);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final TextStyle effectiveStyle = DefaultTextStyle.of( final TextStyle effectiveStyle = DefaultTextStyle.of(
@@ -29,7 +35,7 @@ class MiddleEllipsisText extends StatelessWidget {
final double maxWidth = constraints.maxWidth; final double maxWidth = constraints.maxWidth;
// Measure full text width first. // Measure full text width first.
final fullSpan = TextSpan(text: text, style: effectiveStyle); final fullSpan = TextSpan(text: safeText, style: effectiveStyle);
final fullPainter = TextPainter( final fullPainter = TextPainter(
text: fullSpan, text: fullSpan,
textDirection: direction, textDirection: direction,
@@ -38,7 +44,7 @@ class MiddleEllipsisText extends StatelessWidget {
if (fullPainter.width <= maxWidth) { if (fullPainter.width <= maxWidth) {
return Text( return Text(
text, safeText,
style: effectiveStyle, style: effectiveStyle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
@@ -47,6 +53,11 @@ class MiddleEllipsisText extends StatelessWidget {
); );
} }
// Use grapheme clusters (Characters) to safely split text without
// breaking surrogate pairs or emoji sequences.
final characters = safeText.characters;
final int totalGraphemes = characters.length;
// Pre-measure ellipsis width (used implicitly during search). // Pre-measure ellipsis width (used implicitly during search).
final ellipsisSpan = TextSpan(text: ellipsis, style: effectiveStyle); final ellipsisSpan = TextSpan(text: ellipsis, style: effectiveStyle);
final ellipsisPainter = TextPainter( final ellipsisPainter = TextPainter(
@@ -56,24 +67,25 @@ class MiddleEllipsisText extends StatelessWidget {
)..layout(minWidth: 0, maxWidth: double.infinity); )..layout(minWidth: 0, maxWidth: double.infinity);
final double _ = ellipsisPainter.width; // hint width; not used directly final double _ = ellipsisPainter.width; // hint width; not used directly
// Binary search the maximum number of visible characters (k), split // Binary search the maximum number of visible graphemes (k), split
// between start and end. For a given k, we use ceil(k/2) from start // between start and end. For a given k, we use ceil(k/2) from start
// and floor(k/2) from end. // and floor(k/2) from end.
int low = 0; int low = 0;
int high = text.length; // exclusive upper bound in practice int high = totalGraphemes;
int bestK = 0; int bestK = 0;
String bestStart = ''; String bestStart = '';
String bestEnd = ''; String bestEnd = '';
while (low <= high) { while (low <= high) {
final int k = (low + high) >> 1; // candidate visible char count final int k = (low + high) >> 1; // candidate visible grapheme count
final int leftCount = (k + 1) >> 1; // ceil(k/2) final int leftCount = (k + 1) >> 1; // ceil(k/2)
final int rightCount = k - leftCount; // floor(k/2) final int rightCount = k - leftCount; // floor(k/2)
final String start = text.substring(0, leftCount); // Use Characters.take/takeLast to safely extract grapheme clusters.
final String start = characters.take(leftCount).toString();
final String end = rightCount == 0 final String end = rightCount == 0
? '' ? ''
: text.substring(text.length - rightCount); : characters.takeLast(rightCount).toString();
final trialSpan = TextSpan( final trialSpan = TextSpan(
text: '$start$ellipsis$end', text: '$start$ellipsis$end',
@@ -102,7 +114,7 @@ class MiddleEllipsisText extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
textAlign: textAlign, textAlign: textAlign,
semanticsLabel: semanticsLabel ?? text, semanticsLabel: semanticsLabel ?? safeText,
); );
} }
@@ -113,9 +125,48 @@ class MiddleEllipsisText extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
textAlign: textAlign, textAlign: textAlign,
semanticsLabel: semanticsLabel ?? text, semanticsLabel: semanticsLabel ?? safeText,
); );
}, },
); );
} }
/// Removes unpaired UTF-16 surrogates that would cause "not well-formed
/// UTF-16" errors during text layout.
///
/// A valid UTF-16 string requires:
/// - High surrogates (0xD800-0xDBFF) must be followed by low surrogates
/// - Low surrogates (0xDC00-0xDFFF) must be preceded by high surrogates
static String _sanitizeUtf16(String input) {
if (input.isEmpty) return input;
final buffer = StringBuffer();
for (int i = 0; i < input.length; i++) {
final int codeUnit = input.codeUnitAt(i);
// Check if this is a high surrogate (0xD800-0xDBFF)
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
// Check if next character is a valid low surrogate
if (i + 1 < input.length) {
final int nextCodeUnit = input.codeUnitAt(i + 1);
if (nextCodeUnit >= 0xDC00 && nextCodeUnit <= 0xDFFF) {
// Valid surrogate pair - include both
buffer.writeCharCode(codeUnit);
buffer.writeCharCode(nextCodeUnit);
i++; // Skip the low surrogate in next iteration
continue;
}
}
// Unpaired high surrogate - replace with replacement character
buffer.writeCharCode(0xFFFD);
} else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) {
// Unpaired low surrogate - replace with replacement character
buffer.writeCharCode(0xFFFD);
} else {
// Regular character - include as-is
buffer.writeCharCode(codeUnit);
}
}
return buffer.toString();
}
} }