fix(logging): combine newline-split stdout messages into one log entry (#9757)

* fix(logging): combine newline-split stdout messages into one log entry

* Refactor StdoutEventHandler buffering logic and null handling

* formatting

* Update packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md

Co-authored-by: Kenzie Davisson <43759233+kenzieschmoll@users.noreply.github.com>

---------

Co-authored-by: Kenzie Davisson <43759233+kenzieschmoll@users.noreply.github.com>
diff --git a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart
index fa93986..da6f1ce 100644
--- a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart
+++ b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart
@@ -293,13 +293,13 @@
 
   void _handleConnectionStart(VmServiceWrapper service) {
     // Log stdout events.
-    final stdoutHandler = _StdoutEventHandler(this, 'stdout');
+    final stdoutHandler = StdoutEventHandler(this, 'stdout');
     autoDisposeStreamSubscription(
       service.onStdoutEventWithHistorySafe.listen(stdoutHandler.handle),
     );
 
     // Log stderr events.
-    final stderrHandler = _StdoutEventHandler(this, 'stderr', isError: true);
+    final stderrHandler = StdoutEventHandler(this, 'stderr', isError: true);
     autoDisposeStreamSubscription(
       service.onStderrEventWithHistorySafe.listen(stderrHandler.handle),
     );
@@ -841,44 +841,21 @@
 /// stdout message and its newline. Currently, `foo\n` is sent as two VM events;
 /// we wait for up to 1ms when we get the `foo` event, to see if the next event
 /// is a single newline. If so, we add the newline to the previous log message.
-class _StdoutEventHandler {
-  _StdoutEventHandler(
-    this.loggingController,
-    this.name, {
-    this.isError = false,
-  });
+@visibleForTesting
+class StdoutEventHandler {
+  StdoutEventHandler(this.loggingController, this.name, {this.isError = false});
 
   final LoggingController loggingController;
   final String name;
   final bool isError;
 
-  LogData? buffer;
-  Timer? timer;
+  LogData? _buffer;
+  Timer? _timer;
 
   void handle(Event e) {
     final message = decodeBase64(e.bytes!);
 
-    if (buffer != null) {
-      timer?.cancel();
-
-      if (message == '\n') {
-        loggingController.log(
-          LogData(
-            buffer!.kind,
-            buffer!.details! + message,
-            buffer!.timestamp,
-            summary: buffer!.summary! + message,
-            isError: buffer!.isError,
-            isolateRef: e.isolateRef,
-          ),
-        );
-        buffer = null;
-        return;
-      }
-
-      loggingController.log(buffer!);
-      buffer = null;
-    }
+    if (_handleBufferedMessage(message, e)) return;
 
     const maxLength = 200;
 
@@ -899,13 +876,70 @@
     if (message == '\n') {
       loggingController.log(data);
     } else {
-      buffer = data;
-      timer = Timer(const Duration(milliseconds: 1), () {
-        loggingController.log(buffer!);
-        buffer = null;
-      });
+      _setBuffer(data);
     }
   }
+
+  bool _handleBufferedMessage(String message, Event e) {
+    if (_buffer case final currentBuffer?) {
+      _timer?.cancel();
+
+      if (message == '\n') {
+        loggingController.log(
+          LogData(
+            currentBuffer.kind,
+            currentBuffer.details! + message,
+            currentBuffer.timestamp,
+            summary: currentBuffer.summary! + message,
+            isError: currentBuffer.isError,
+            isolateRef: e.isolateRef,
+          ),
+        );
+        _buffer = null;
+        return true;
+      }
+
+      // If the buffered message ends with a newline, the next message is a
+      // continuation of the same print statement (e.g. debugPrint('line1\nline2')
+      // is sent by the VM as two events: 'line1\n' and 'line2'). Combine them
+      // into a single log entry.
+      // See: https://github.com/flutter/devtools/issues/9557
+      if (currentBuffer.details!.endsWith('\n')) {
+        _setBuffer(
+          LogData(
+            currentBuffer.kind,
+            currentBuffer.details! + message,
+            currentBuffer.timestamp,
+            summary: currentBuffer.summary,
+            isError: currentBuffer.isError,
+            isolateRef: e.isolateRef,
+          ),
+        );
+        return true;
+      }
+
+      loggingController.log(currentBuffer);
+      _buffer = null;
+    }
+    return false;
+  }
+
+  void _setBuffer(LogData data) {
+    _buffer = data;
+    _timer?.cancel();
+    _timer = Timer(const Duration(milliseconds: 1), () {
+      if (_buffer case final currentBuffer?) {
+        loggingController.log(currentBuffer);
+        _buffer = null;
+      }
+    });
+  }
+
+  @visibleForTesting
+  LogData? get buffer => _buffer;
+
+  @visibleForTesting
+  Timer? get timer => _timer;
 }
 
 bool _isNotNull(InstanceRef? serviceRef) {
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index d729c95..d4e8482 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -43,7 +43,7 @@
 
 ## Logging updates
 
-TODO: Remove this section if there are not any updates.
+- Fixed an issue where log messages containing newline characters were incorrectly split into multiple separate entries in the Logging screen. [#9757](https://github.com/flutter/devtools/pull/9757)
 
 ## App size tool updates
 
diff --git a/packages/devtools_app/test/screens/logging/logging_controller_test.dart b/packages/devtools_app/test/screens/logging/logging_controller_test.dart
index 4362b58..85b534e 100644
--- a/packages/devtools_app/test/screens/logging/logging_controller_test.dart
+++ b/packages/devtools_app/test/screens/logging/logging_controller_test.dart
@@ -381,4 +381,55 @@
       },
     );
   });
+
+  group('StdoutEventHandler', () {
+    late LoggingController controller;
+    late StdoutEventHandler stdoutHandler;
+
+    Event stdoutEvent(String message, {int? timestamp}) {
+      return Event(
+        bytes: base64Encode(utf8.encode(message)),
+        timestamp: timestamp ?? ++timestampCounter,
+      );
+    }
+
+    setUp(() {
+      setGlobal(ServiceConnectionManager, FakeServiceConnectionManager());
+      setGlobal(MessageBus, MessageBus());
+      setGlobal(PreferencesController, PreferencesController());
+
+      controller = LoggingController()..init();
+      stdoutHandler = StdoutEventHandler(controller, 'stdout');
+    });
+
+    test('combines newline-terminated continuation into one log', () async {
+      stdoutHandler.handle(stdoutEvent('line1\n'));
+      stdoutHandler.handle(stdoutEvent('line2'));
+
+      await Future<void>.delayed(const Duration(milliseconds: 10));
+
+      expect(controller.data, hasLength(1));
+      expect(controller.data.single.kind, 'stdout');
+      expect(controller.data.single.details, 'line1\nline2');
+    });
+
+    test('still combines message followed by lone newline', () {
+      stdoutHandler.handle(stdoutEvent('line1'));
+      stdoutHandler.handle(stdoutEvent('\n'));
+
+      expect(controller.data, hasLength(1));
+      expect(controller.data.single.details, 'line1\n');
+    });
+
+    test('keeps separate entries for distinct non-newline chunks', () async {
+      stdoutHandler.handle(stdoutEvent('line1'));
+      stdoutHandler.handle(stdoutEvent('line2'));
+
+      await Future<void>.delayed(const Duration(milliseconds: 10));
+
+      expect(controller.data, hasLength(2));
+      expect(controller.data[0].details, 'line1');
+      expect(controller.data[1].details, 'line2');
+    });
+  });
 }