diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart index 388634e..bb786d4 100644 --- a/lib/features/chat/services/voice_call_service.dart +++ b/lib/features/chat/services/voice_call_service.dart @@ -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( diff --git a/lib/shared/widgets/middle_ellipsis_text.dart b/lib/shared/widgets/middle_ellipsis_text.dart index 9d353fd..58f8fc4 100644 --- a/lib/shared/widgets/middle_ellipsis_text.dart +++ b/lib/shared/widgets/middle_ellipsis_text.dart @@ -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(); + } }