Merge pull request #302 from cogwheel0/improve-middle-ellipsis-text-handling

fix(widget): Improve MiddleEllipsisText Unicode and surrogate handling
This commit is contained in:
cogwheel
2025-12-20 22:31:43 +05:30
committed by GitHub
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)
_keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic(
const Duration(minutes: 5),
(_) async {
final success = await BackgroundStreamingHandler.instance.keepAlive();
if (!success) {
// Keep-alive failed but don't stop the call - service may still work
developer.log(
'Voice call keep-alive failed',
name: 'VoiceCallService',
level: 900, // WARNING
);
}
},
);
_keepAliveTimer = Timer.periodic(const Duration(minutes: 5), (_) async {
final success = await BackgroundStreamingHandler.instance.keepAlive();
if (!success) {
// Keep-alive failed but don't stop the call - service may still work
developer.log(
'Voice call keep-alive failed',
name: 'VoiceCallService',
level: 900, // WARNING
);
}
});
// Set up socket event listener for assistant responses
_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
/// 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 {
final String text;
final TextStyle? style;
@@ -20,6 +23,9 @@ class MiddleEllipsisText extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Sanitize text to remove any unpaired surrogates that could cause crashes.
final String safeText = _sanitizeUtf16(text);
return LayoutBuilder(
builder: (context, constraints) {
final TextStyle effectiveStyle = DefaultTextStyle.of(
@@ -29,7 +35,7 @@ class MiddleEllipsisText extends StatelessWidget {
final double maxWidth = constraints.maxWidth;
// Measure full text width first.
final fullSpan = TextSpan(text: text, style: effectiveStyle);
final fullSpan = TextSpan(text: safeText, style: effectiveStyle);
final fullPainter = TextPainter(
text: fullSpan,
textDirection: direction,
@@ -38,7 +44,7 @@ class MiddleEllipsisText extends StatelessWidget {
if (fullPainter.width <= maxWidth) {
return Text(
text,
safeText,
style: effectiveStyle,
maxLines: 1,
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).
final ellipsisSpan = TextSpan(text: ellipsis, style: effectiveStyle);
final ellipsisPainter = TextPainter(
@@ -56,24 +67,25 @@ class MiddleEllipsisText extends StatelessWidget {
)..layout(minWidth: 0, maxWidth: double.infinity);
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
// and floor(k/2) from end.
int low = 0;
int high = text.length; // exclusive upper bound in practice
int high = totalGraphemes;
int bestK = 0;
String bestStart = '';
String bestEnd = '';
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 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
? ''
: text.substring(text.length - rightCount);
: characters.takeLast(rightCount).toString();
final trialSpan = TextSpan(
text: '$start$ellipsis$end',
@@ -102,7 +114,7 @@ class MiddleEllipsisText extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.clip,
textAlign: textAlign,
semanticsLabel: semanticsLabel ?? text,
semanticsLabel: semanticsLabel ?? safeText,
);
}
@@ -113,9 +125,48 @@ class MiddleEllipsisText extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.clip,
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();
}
}