Get versions of GHA and GMSCore in generated report

PiperOrigin-RevId: 601627538
diff --git a/ui_automator/test_reporter.py b/ui_automator/test_reporter.py
index ab906c8..194b26c 100644
--- a/ui_automator/test_reporter.py
+++ b/ui_automator/test_reporter.py
@@ -20,10 +20,12 @@
 """
 
 import collections
+from collections.abc import Mapping
 import io
 import logging
 import os
 import time
+from typing import TypedDict, NotRequired
 import unittest
 
 from absl.testing import xml_reporter
@@ -34,7 +36,7 @@
     'Commission to GHA': 'test_commission',
     'Removing from GHA': 'test_decommission',
 })
-
+_NA = 'n/a'
 _SUMMARY_COL_INDENTS = 25
 _TEST_CASE_TITLE_INDENTS = 25
 _TEST_RESULT_INDENTS = 8
@@ -49,6 +51,15 @@
   )
 
 
+class ReportInfo(TypedDict):
+  """Type guard for summary of running unit tests."""
+  gha_version: NotRequired[str | None]
+  gms_core_version: NotRequired[str | None]
+  hub_version: NotRequired[str | None]
+  device_firmware: NotRequired[str | None]
+  dut: NotRequired[str | None]
+
+
 # pylint: disable=protected-access
 class _TestCaseResult(xml_reporter._TestCaseResult):
   """Private helper for TestResult."""
@@ -189,7 +200,17 @@
     self._logger = logger
 
   @classmethod
-  def write_summary_in_txt(cls) -> None:
+  def write_summary_in_txt(
+      cls,
+      report_info: ReportInfo | None = None,
+  ) -> None:
+    """Saves summary to a txt file.
+
+    Args:
+      report_info: Versions and device info displayed in generated txt report.
+        Versions includes GHA, GMSCore, hub and device firmware. Device info is
+        in <model, type, protocol> format, e.g. <X123123, LIGHT, Matter>.
+    """
     if not cls._instance:
       return
 
@@ -260,13 +281,34 @@
     success_rate = round(
         100.0 * float(total_successful_runs) / float(total_runs)
     )
+    report_info = report_info or {}
     # TODO(b/317837867): Replace all placeholders with real values.
     rows: list[list[str]] = []
     rows.append(['Summary', '', 'Version Info', ''])
-    rows.append(['DUT:', 'placeholder', 'GHA', 'placeholder'])
-    rows.append(['Test Time:', test_date, 'GMSCore', 'placeholder'])
-    rows.append(['Duration:', duration, 'Hub', 'placeholder'])
-    rows.append(['Number of runs:', str(total_runs), 'Device', 'placeholder'])
+    rows.append([
+        'DUT:',
+        report_info.get('dut', _NA),
+        'GHA',
+        report_info.get('gha_version', _NA),
+    ])
+    rows.append([
+        'Test Time:',
+        test_date,
+        'GMSCore',
+        report_info.get('gms_core_version', _NA),
+    ])
+    rows.append([
+        'Duration:',
+        duration,
+        'Hub',
+        report_info.get('hub_version', _NA),
+    ])
+    rows.append([
+        'Number of runs:',
+        str(total_runs),
+        'Device',
+        report_info.get('device_firmware', _NA),
+    ])
     rows.append([
         'Success Rate:',
         f'{success_rate}%({total_successful_runs}/{total_runs})',
@@ -332,11 +374,18 @@
     self._xml_file_path = xml_file_path or default_xml_file_path
     super().__init__(xml_stream=open(self._xml_file_path, 'w'), *args, **kwargs)
 
-  def run(self, suite: unittest.TestSuite) -> None:
+  def run(
+      self,
+      suite: unittest.TestSuite,
+      report_info: Mapping[str, str | None] | None = None,
+  ) -> None:
     """Runs tests and generates reports in XML and TXT.
 
     Args:
       suite: TestSuite should be run for regression testing.
+      report_info: Versions and device info displayed in generated txt report.
+        Versions includes GHA, GMSCore, hub and device firmware. Device info is
+        in <model, type, protocol> format, e.g. <X123123, LIGHT, Matter>.
     """
     try:
       super().run(suite)
@@ -346,7 +395,7 @@
       TestResult.writeAllResultsToXml()
     finally:
       self._logger.info(f'Xml file saved to {self._xml_file_path}.')
-      TestResult.write_summary_in_txt()
+      TestResult.write_summary_in_txt(report_info=report_info)
 
   def _makeResult(self):
     return self._TEST_RESULT_CLASS(
diff --git a/ui_automator/test_reporter_test.py b/ui_automator/test_reporter_test.py
index 0470fde..3cc6678 100644
--- a/ui_automator/test_reporter_test.py
+++ b/ui_automator/test_reporter_test.py
@@ -187,8 +187,8 @@
 
   @mock.patch('builtins.open', autospec=True)
   def test_write_summary_in_txt_saves_summary_to_a_file(self, mock_open):
-    fake_stream = io.StringIO()
-    mock_open.return_value = fake_stream
+    txt_stream = io.StringIO()
+    mock_open.return_value = txt_stream
     start_time = 100
     end_time = 200
     result = self._make_result(start_time, end_time, 6)
@@ -197,12 +197,20 @@
     # 5 is the number explicitly add in _make_result.
     run_time = end_time - start_time + 5
     now = time.localtime()
+    fake_report_info = {
+        'gha_version': '1',
+        'gms_core_version': '2',
+        'hub_version': '1',
+        'device_firmware': '1',
+        'dut': '1',
+    }
     expected_summary = re.escape(
         unit_test_utils.make_summary(
             test_date=time.strftime('%Y/%m/%d', now),
             duration=test_reporter.duration_formatter(run_time),
             total_runs=3,
             total_successful_runs=2,
+            **fake_report_info,
         )
     )
     res_of_test_commission = ['FAIL', 'PASS', 'PASS']
@@ -248,17 +256,17 @@
     result.stopTestRun()
     result.printErrors()
 
-    with mock.patch.object(fake_stream, 'close'):
+    with mock.patch.object(txt_stream, 'close'):
       with mock.patch.object(time, 'localtime', return_value=now):
-        test_reporter.TestResult.write_summary_in_txt()
+        test_reporter.TestResult.write_summary_in_txt(fake_report_info)
 
     mock_open.assert_called_once_with(
         f"summary_{time.strftime('%Y%m%d%H%M%S', now)}.txt",
         'w',
         encoding='utf-8',
     )
-    self.assertRegex(fake_stream.getvalue(), expected_summary)
-    self.assertRegex(fake_stream.getvalue(), expected_test_case_result)
+    self.assertRegex(txt_stream.getvalue(), expected_summary)
+    self.assertRegex(txt_stream.getvalue(), expected_test_case_result)
 
   @mock.patch.object(
       xml_reporter.TextAndXMLTestRunner,
diff --git a/ui_automator/ui_automator.py b/ui_automator/ui_automator.py
index fdd6c51..181e8ed 100644
--- a/ui_automator/ui_automator.py
+++ b/ui_automator/ui_automator.py
@@ -17,6 +17,7 @@
 A python controller that can trigger mobly UI automator snippet to achieve some
 automated UI operations on Android phones.
 """
+import collections
 from concurrent import futures
 import enum
 import functools
@@ -375,11 +376,32 @@
 
     runner = test_reporter.TestRunner(logger=self._logger)
     try:
-      runner.run(suite)
+      runner.run(suite=suite, report_info=self.get_report_info())
     finally:
       self._is_reg_test_finished = True
       executor.shutdown(wait=False)
 
+  def get_report_info(self) -> test_reporter.ReportInfo:
+    """Gets report info for regression tests.
+
+    Returns:
+        report_info: Versions and device info displayed in generated txt report.
+        Versions includes GHA, GMSCore, hub and device firmware. Device info is
+        in <model, type, protocol> format, e.g. <X123123, LIGHT, Matter>.
+    """
+    temp_info = collections.defaultdict()
+    if self._connected_device:
+      temp_info['gha_version'] = ad.get_apk_version(
+          self._connected_device, 'com.google.android.apps.chromecast.app'
+      )
+      temp_info['gms_core_version'] = ad.get_apk_version(
+          self._connected_device, 'com.google.android.gms'
+      )
+
+    report_info: test_reporter.ReportInfo = {**temp_info}
+
+    return report_info
+
   def _get_mbs_apk_path(self) -> str:
     return os.path.join(
         os.path.dirname(os.path.abspath(__file__)),
diff --git a/ui_automator/ui_automator_test.py b/ui_automator/ui_automator_test.py
index 3d506d1..fa0d1f7 100644
--- a/ui_automator/ui_automator_test.py
+++ b/ui_automator/ui_automator_test.py
@@ -650,9 +650,6 @@
     mock_commission_device.assert_not_called()
     mock_run_regression_tests.assert_not_called()
 
-  @flagsaver.flagsaver(
-      (ui_automator._COMMISSION, ['m5stack', '34970112332', 'Office'])
-  )
   @mock.patch.object(time, 'sleep', autospec=True)
   @mock.patch('builtins.open', autospec=True)
   @mock.patch.object(android_device, 'get_all_instances', autospec=True)
@@ -689,9 +686,6 @@
     )
     self.assertEqual(mock_open.call_count, 2)
 
-  @flagsaver.flagsaver(
-      (ui_automator._COMMISSION, ['m5stack', '34970112332', 'Office'])
-  )
   @mock.patch.object(time, 'sleep', autospec=True)
   @mock.patch('builtins.open', autospec=True)
   @mock.patch.object(android_device, 'get_all_instances', autospec=True)
@@ -747,9 +741,6 @@
           gha_room='Office',
       )
 
-  @flagsaver.flagsaver(
-      (ui_automator._COMMISSION, ['m5stack', '34970112332', 'Office'])
-  )
   @mock.patch.object(time, 'sleep', autospec=True)
   @mock.patch('builtins.open', autospec=True)
   @mock.patch.object(android_device, 'get_all_instances', autospec=True)
@@ -1008,6 +999,67 @@
     self.assertRegex(testcase6, expected_testcase6_re)
     mock_exit.assert_called_once()
 
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  def test_get_report_info_includes_apk_versions_with_device_connected(
+      self,
+      mock_get_all_instances,
+  ):
+    self.mock_android_device.adb.shell.side_effect = [
+        b'versionName=0.0.0\n',
+        b'versionName=0.0.1\n',
+    ]
+    mock_get_all_instances.return_value = [self.mock_android_device]
+    self.ui_automator.load_device()
+
+    report_info = self.ui_automator.get_report_info()
+
+    self.assertIsNotNone(report_info)
+    self.assertEqual(report_info.get('gha_version'), '0.0.0')
+    self.assertEqual(report_info.get('gms_core_version'), '0.0.1')
+
+  def test_get_report_info_returns_empty_dict_when_no_device_connected(
+      self,
+  ):
+    report_info = self.ui_automator.get_report_info()
+
+    self.assertDictEqual(report_info, {})
+
+  @mock.patch.object(time, 'sleep', autospec=True)
+  @mock.patch('builtins.open', autospec=True)
+  @mock.patch.object(android_device, 'get_all_instances', autospec=True)
+  @mock.patch.object(ui_automator.UIAutomator, 'get_report_info', autospec=True)
+  def test_run_regression_tests_should_insert_report_info_into_summary(
+      self,
+      mock_get_report_info,
+      mock_get_all_instances,
+      mock_open,
+      mock_sleep,
+  ):
+    mock_sleep.return_value = None
+    fake_report_info: test_reporter.ReportInfo = {
+        'gha_version': '0.0.0',
+        'gms_core_version': '0.0.1',
+    }
+    mock_get_report_info.return_value = fake_report_info
+    mock_get_all_instances.return_value = [self.mock_android_device]
+
+    with mock.patch.object(
+        test_reporter.TestResult, 'write_summary_in_txt', autospec=True
+    ) as mock_write_summary_in_txt:
+      self.ui_automator.run_regression_tests(
+          3,
+          ui_automator.RegTestSuiteType.COMMISSION,
+          device_name='m5stack',
+          pairing_code='34970112332',
+          gha_room='Office',
+      )
+
+    self.assertEqual(
+        mock_write_summary_in_txt.call_args.kwargs.get('report_info'),
+        fake_report_info,
+    )
+    mock_open.assert_called_once()
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/ui_automator/unit_test_utils.py b/ui_automator/unit_test_utils.py
index d383152..7bde017 100644
--- a/ui_automator/unit_test_utils.py
+++ b/ui_automator/unit_test_utils.py
@@ -66,14 +66,14 @@
   Returns:
     Test summary.
   """
-  dut = kwargs.get('dut', 'placeholder')
-  gha = kwargs.get('gha', 'placeholder')
-  test_date = kwargs.get('test_date', 'placeholder')
-  gms_core = kwargs.get('gms_core', 'placeholder')
+  dut = kwargs.get('dut', 'n/a')
+  gha = kwargs.get('gha_version', 'n/a')
+  test_date = kwargs.get('test_date', 'n/a')
+  gms_core = kwargs.get('gms_core_version', 'n/a')
   duration = kwargs.get('duration', 0)
-  hub = kwargs.get('hub', 'placeholder')
+  hub = kwargs.get('hub_version', 'n/a')
   total_runs = kwargs.get('total_runs', 0)
-  device = kwargs.get('device', 'placeholder')
+  device = kwargs.get('device_firmware', 'n/a')
   total_successful_runs = kwargs.get('total_successful_runs', 0)
   success_rate = round(100.0 * float(total_successful_runs) / float(total_runs))
   rows: list[list[str]] = []