Repository: beeware/briefcase

Issue:
Android crash logs are hidden if phone and computer times aren't in sync
### Describe the bug

If an Android app is well behaved, Briefcase will start the app, detect the PID of the running app, then display the logs for that PID.

If an app crashes on startup, there may not be enough time for Briefcase to detect the PID; in this case, Briefcase moves into a fallback mode, and prints the last 10 seconds of logs from *any* PID, with a filter that captures important Toga/Chaquopy log sections.

Briefcase establishes this "10 second" window by using the local system time. However, this timestamp is passed to `adb` as a log filter (`-t`), and it is the *phone's* clock that is used to evaluate the log filter.

So - if the computer and the phone disagree on the current time, it is possible for the app logs to appear empty, even though there are plenty of logs - just outside the "10 second" window.

### Steps to reproduce

1. Generate a "hello world" app
2. Put `raise Exception()` at the top of `app.py`
3. Deliberately change your computer's clock so that it is 1 minute faster than your phone
4. Run the app on Android.

It may be necessary to use a physical phone; an emulator will be using the host system clock.


### Expected behavior

Logs for the crash should be generated in Briefcase.

### Screenshots

_No response_

### Environment

- Operating System: Any
- Python version: Any
- Software versions:
  - Briefcase: 0.3.13+


### Logs

```

```


### Additional context

One fix is to use the *phone's* perception of time to establish the start of the logging window. We currently use `datetime.now()` to get the start time prior to launching the app; we could instead use `adb shell date`, and parse the result. This would ensure that the time filter passed to  `adb logcat` will be using a common reference time.

Discussion:

Patch:
diff --git a/changes/1146.bugfix.rst b/changes/1146.bugfix.rst
new file mode 100644
index 00000000..3c16dd64
--- /dev/null
+++ b/changes/1146.bugfix.rst
@@ -0,0 +1,1 @@
+The run command now ensures Android logging is shown when the datetime on the device is different from the host machine.
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index d3a8e4a5..59bf8e77 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -1293,10 +1293,10 @@ class ADB:
                     f"Unable to interrogate AVD name of device {self.device}"
                 ) from e
 
-    def has_booted(self):
+    def has_booted(self) -> bool:
         """Determine if the device has completed booting.
 
-        :returns True if it has booted; False otherwise.
+        :returns: True if it has booted; False otherwise.
         """
         try:
             # When the sys.boot_completed property of the device
@@ -1326,8 +1326,6 @@ class ADB:
         # checking that they are valid, then parsing output to notice errors.
         # This keeps performance good in the success case.
         try:
-            # Capture `stderr` so that if the process exits with failure, the
-            # stderr data is in `e.output`.
             return self.tools.subprocess.check_output(
                 [
                     os.fsdecode(self.tools.android_sdk.adb_path),
@@ -1349,8 +1347,7 @@ class ADB:
         """Install an APK file on an Android device.
 
         :param apk_path: The path of the Android APK file to install.
-
-        Returns `None` on success; raises an exception on failure.
+        :returns: `None` on success; raises an exception on failure.
         """
         try:
             self.run("install", "-r", apk_path)
@@ -1363,8 +1360,7 @@ class ADB:
         """Force-stop an app, specified as a package name.
 
         :param package: The name of the Android package, e.g., com.username.myapp.
-
-        Returns `None` on success; raises an exception on failure.
+        :returns: `None` on success; raises an exception on failure.
         """
         # In my testing, `force-stop` exits with status code 0 (success) so long
         # as you pass a package name, even if the package does not exist, or the
@@ -1379,15 +1375,14 @@ class ADB:
     def start_app(self, package: str, activity: str, passthrough: list[str]):
         """Start an app, specified as a package name & activity name.
 
-        :param package: The name of the Android package, e.g., com.username.myapp.
-        :param activity: The activity of the APK to start.
-        :param passthrough: Arguments to pass to the app.
-
-        Returns `None` on success; raises an exception on failure.
-
         If you have an APK file, and you are not sure of the package or activity
         name, you can find it using `aapt dump badging filename.apk` and looking
         for "package" and "launchable-activity" in the output.
+
+        :param package: The name of the Android package, e.g., com.username.myapp.
+        :param activity: The activity of the APK to start.
+        :param passthrough: Arguments to pass to the app.
+        :returns: `None` on success; raises an exception on failure.
         """
         try:
             # `am start` also accepts string array extras, but we pass the arguments as a
@@ -1429,7 +1424,7 @@ Activity class not found while starting app.
                 f"Unable to start {package}/{activity} on {self.device}"
             ) from e
 
-    def logcat(self, pid: str):
+    def logcat(self, pid: str) -> subprocess.Popen:
         """Start following the adb log for the device.
 
         :param pid: The PID whose logs you want to display.
@@ -1519,4 +1514,18 @@ Activity class not found while starting app.
         try:
             self.run("emu", "kill")
         except subprocess.CalledProcessError as e:
-            raise BriefcaseCommandError("Error starting ADB logcat.") from e
+            raise BriefcaseCommandError("Error stopping the Android emulator.") from e
+
+    def datetime(self) -> datetime:
+        """Obtain the device's current date/time.
+
+        This date/time is naive (i.e. not timezone aware) and in the device's "local"
+        time. Therefore, it may be quite different from the date/time for Briefcase and
+        caution should be used if comparing it to machine's "local" time.
+        """
+        datetime_format = "%Y-%m-%d %H:%M:%S"
+        try:
+            device_datetime = self.run("shell", "date", f"+'{datetime_format}'").strip()
+            return datetime.strptime(device_datetime, datetime_format)
+        except (ValueError, subprocess.CalledProcessError) as e:
+            raise BriefcaseCommandError("Error obtaining device date/time.") from e
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index 859f3a8b..4788e198 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -277,19 +277,13 @@ class GradleRunCommand(GradleMixin, RunCommand):
                 extra = f" (with {' '.join(extra_emulator_args)})"
             else:
                 extra = ""
-            self.logger.info(
-                f"Starting emulator {avd}{extra}...",
-                prefix=app.app_name,
-            )
+            self.logger.info(f"Starting emulator {avd}{extra}...", prefix=app.app_name)
             device, name = self.tools.android_sdk.start_emulator(
                 avd, extra_emulator_args
             )
 
         try:
-            if test_mode:
-                label = "test suite"
-            else:
-                label = "app"
+            label = "test suite" if test_mode else "app"
 
             self.logger.info(
                 f"Starting {label} on {name} (device ID {device})", prefix=app.app_name
@@ -313,29 +307,27 @@ class GradleRunCommand(GradleMixin, RunCommand):
 
             # To start the app, we launch `org.beeware.android.MainActivity`.
             with self.input.wait_bar(f"Launching {label}..."):
-                # Any log after this point must be associated with the new instance
-                start_time = datetime.datetime.now()
+                # capture the earliest time for device logging in case PID not found
+                device_start_time = adb.datetime()
+
                 adb.start_app(package, "org.beeware.android.MainActivity", passthrough)
-                pid = None
-                attempts = 0
-                delay = 0.01
 
                 # Try to get the PID for 5 seconds.
-                fail_time = start_time + datetime.timedelta(seconds=5)
+                pid = None
+                fail_time = datetime.datetime.now() + datetime.timedelta(seconds=5)
                 while not pid and datetime.datetime.now() < fail_time:
                     # Try to get the PID; run in quiet mode because we may
                     # need to do this a lot in the next 5 seconds.
                     pid = adb.pidof(package, quiet=True)
                     if not pid:
-                        time.sleep(delay)
-                    attempts += 1
+                        time.sleep(0.01)
 
             if pid:
                 self.logger.info(
                     "Following device log output (type CTRL-C to stop log)...",
                     prefix=app.app_name,
                 )
-                # Start the app in a way that lets us stream the logs
+                # Start adb's logcat in a way that lets us stream the logs
                 log_popen = adb.logcat(pid=pid)
 
                 # Stream the app logs.
@@ -352,13 +344,11 @@ class GradleRunCommand(GradleMixin, RunCommand):
             else:
                 self.logger.error("Unable to find PID for app", prefix=app.app_name)
                 self.logger.error("Logs for launch attempt follow...")
+                self.logger.error("=" * 75)
 
                 # Show the log from the start time of the app
-                self.logger.error("=" * 75)
+                adb.logcat_tail(since=device_start_time)
 
-                # Pad by a few seconds because the android emulator's clock and the
-                # local system clock may not be perfectly aligned.
-                adb.logcat_tail(since=start_time - datetime.timedelta(seconds=10))
                 raise BriefcaseCommandError(f"Problem starting app {app.app_name!r}")
         finally:
             if shutdown_on_exit:


Test patch:
diff --git a/tests/integrations/android_sdk/ADB/test_avd_name.py b/tests/integrations/android_sdk/ADB/test_avd_name.py
index b08c0fc6..913cb259 100644
--- a/tests/integrations/android_sdk/ADB/test_avd_name.py
+++ b/tests/integrations/android_sdk/ADB/test_avd_name.py
@@ -4,13 +4,11 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError, InvalidDeviceError
-from briefcase.integrations.android_sdk import ADB
 
 
-def test_emulator(mock_tools, capsys):
+def test_emulator(adb, capsys):
     """Invoking `avd_name()` on an emulator returns the AVD."""
     # Mock out the adb response for an emulator
-    adb = ADB(mock_tools, "deafbeefcafe")
     adb.run = MagicMock(return_value="exampledevice\nOK\n")
 
     # Invoke avd_name
@@ -20,10 +18,9 @@ def test_emulator(mock_tools, capsys):
     adb.run.assert_called_once_with("emu", "avd", "name")
 
 
-def test_device(mock_tools, capsys):
+def test_device(adb, capsys):
     """Invoking `avd_name()` on a device returns None."""
     # Mock out the adb response for a physical device
-    adb = ADB(mock_tools, "deafbeefcafe")
     adb.run = MagicMock(
         side_effect=subprocess.CalledProcessError(returncode=1, cmd="emu avd name")
     )
@@ -35,10 +32,9 @@ def test_device(mock_tools, capsys):
     adb.run.assert_called_once_with("emu", "avd", "name")
 
 
-def test_adb_failure(mock_tools, capsys):
+def test_adb_failure(adb, capsys):
     """If `adb()` fails for a miscellaneous reason, an error is raised."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(
         side_effect=subprocess.CalledProcessError(returncode=69, cmd="emu avd name")
     )
@@ -51,10 +47,9 @@ def test_adb_failure(mock_tools, capsys):
     adb.run.assert_called_once_with("emu", "avd", "name")
 
 
-def test_invalid_device(mock_tools, capsys):
+def test_invalid_device(adb, capsys):
     """Invoking `avd_name()` on an invalid device raises an error."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(side_effect=InvalidDeviceError("device", "exampleDevice"))
 
     # Invoke install
diff --git a/tests/integrations/android_sdk/ADB/test_datetime.py b/tests/integrations/android_sdk/ADB/test_datetime.py
new file mode 100644
index 00000000..3347cc67
--- /dev/null
+++ b/tests/integrations/android_sdk/ADB/test_datetime.py
@@ -0,0 +1,48 @@
+import subprocess
+from datetime import datetime
+from unittest.mock import Mock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+
+@pytest.mark.parametrize(
+    "device_output, expected_datetime",
+    [
+        ("2023-07-12 09:28:04", datetime(2023, 7, 12, 9, 28, 4)),
+        ("2023-07-12 09:28:04\n", datetime(2023, 7, 12, 9, 28, 4)),
+        ("2023-7-12 9:28:04", datetime(2023, 7, 12, 9, 28, 4)),
+        ("2023-12-2 14:28:04", datetime(2023, 12, 2, 14, 28, 4)),
+    ],
+)
+def test_datetime_success(adb, device_output, expected_datetime):
+    """adb.datetime() returns `datetime` for device."""
+    adb.run = Mock(return_value=device_output)
+
+    assert adb.datetime() == expected_datetime
+    adb.run.assert_called_once_with("shell", "date", "+'%Y-%m-%d %H:%M:%S'")
+
+
+def test_datetime_failure_call(adb):
+    """adb.datetime() fails in subprocess call."""
+    adb.run = Mock(
+        side_effect=subprocess.CalledProcessError(returncode=1, cmd="adb shell ...")
+    )
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="Error obtaining device date/time.",
+    ):
+        adb.datetime()
+
+
+def test_datetime_failure_bad_value(adb):
+    """adb.datetime() fails in output conversion."""
+    adb.run = Mock(return_value="the date is jan 1 1970")
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match="Error obtaining device date/time.",
+    ):
+        adb.datetime()
diff --git a/tests/integrations/android_sdk/ADB/test_force_stop_app.py b/tests/integrations/android_sdk/ADB/test_force_stop_app.py
index 01ff7df6..ce87010b 100644
--- a/tests/integrations/android_sdk/ADB/test_force_stop_app.py
+++ b/tests/integrations/android_sdk/ADB/test_force_stop_app.py
@@ -4,13 +4,11 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError, InvalidDeviceError
-from briefcase.integrations.android_sdk import ADB
 
 
-def test_force_stop_app(mock_tools, capsys):
+def test_force_stop_app(adb, capsys):
     """Invoking `force_stop_app()` calls `run()` with the appropriate parameters."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(return_value="example normal adb output")
 
     # Invoke force_stop_app
@@ -26,10 +24,9 @@ def test_force_stop_app(mock_tools, capsys):
     assert "normal adb output" not in capsys.readouterr()
 
 
-def test_force_top_fail(mock_tools, capsys):
+def test_force_top_fail(adb, capsys):
     """If `force_stop_app()` fails, an error is raised."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(
         side_effect=subprocess.CalledProcessError(returncode=69, cmd="force-stop")
     )
@@ -44,10 +41,9 @@ def test_force_top_fail(mock_tools, capsys):
     )
 
 
-def test_invalid_device(mock_tools, capsys):
+def test_invalid_device(adb, capsys):
     """Invoking `force_stop_app()` on an invalid device raises an error."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(side_effect=InvalidDeviceError("device", "exampleDevice"))
 
     # Invoke force_stop_app
diff --git a/tests/integrations/android_sdk/ADB/test_has_booted.py b/tests/integrations/android_sdk/ADB/test_has_booted.py
index 012f7de9..c45b00b4 100644
--- a/tests/integrations/android_sdk/ADB/test_has_booted.py
+++ b/tests/integrations/android_sdk/ADB/test_has_booted.py
@@ -4,13 +4,11 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError, InvalidDeviceError
-from briefcase.integrations.android_sdk import ADB
 
 
-def test_booted(mock_tools, capsys):
+def test_booted(adb, capsys):
     """A booted device returns true."""
     # Mock out the adb response for an emulator
-    adb = ADB(mock_tools, "deafbeefcafe")
     adb.run = MagicMock(return_value="1\n")
 
     # Invoke avd_name
@@ -20,10 +18,9 @@ def test_booted(mock_tools, capsys):
     adb.run.assert_called_once_with("shell", "getprop", "sys.boot_completed")
 
 
-def test_not_booted(mock_tools, capsys):
+def test_not_booted(adb, capsys):
     """A non-booted device returns False."""
     # Mock out the adb response for an emulator
-    adb = ADB(mock_tools, "deafbeefcafe")
     adb.run = MagicMock(return_value="\n")
 
     # Invoke avd_name
@@ -33,10 +30,9 @@ def test_not_booted(mock_tools, capsys):
     adb.run.assert_called_once_with("shell", "getprop", "sys.boot_completed")
 
 
-def test_adb_failure(mock_tools, capsys):
+def test_adb_failure(adb, capsys):
     """If ADB fails, an error is raised."""
     # Mock out the adb response for an emulator
-    adb = ADB(mock_tools, "deafbeefcafe")
     adb.run = MagicMock(
         side_effect=subprocess.CalledProcessError(returncode=69, cmd="emu avd name")
     )
@@ -49,10 +45,9 @@ def test_adb_failure(mock_tools, capsys):
     adb.run.assert_called_once_with("shell", "getprop", "sys.boot_completed")
 
 
-def test_invalid_device(mock_tools, capsys):
+def test_invalid_device(adb, capsys):
     """If the device ID is invalid, an error is raised."""
     # Mock out the adb response for an emulator
-    adb = ADB(mock_tools, "not-a-device")
     adb.run = MagicMock(side_effect=InvalidDeviceError("device", "exampleDevice"))
 
     # Invoke avd_name
diff --git a/tests/integrations/android_sdk/ADB/test_install_apk.py b/tests/integrations/android_sdk/ADB/test_install_apk.py
index 015714e3..82f6caeb 100644
--- a/tests/integrations/android_sdk/ADB/test_install_apk.py
+++ b/tests/integrations/android_sdk/ADB/test_install_apk.py
@@ -4,13 +4,11 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError, InvalidDeviceError
-from briefcase.integrations.android_sdk import ADB
 
 
-def test_install_apk(mock_tools, capsys):
+def test_install_apk(adb, capsys):
     """Invoking `install_apk()` calls `run()` with the appropriate parameters."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(return_value="example normal adb output")
 
     # Invoke install
@@ -24,10 +22,9 @@ def test_install_apk(mock_tools, capsys):
     assert "normal adb output" not in capsys.readouterr()
 
 
-def test_install_failure(mock_tools, capsys):
+def test_install_failure(adb, capsys):
     """If `install_apk()` fails, an error is raised."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(
         side_effect=subprocess.CalledProcessError(returncode=2, cmd="install")
     )
@@ -40,10 +37,9 @@ def test_install_failure(mock_tools, capsys):
     adb.run.assert_called_once_with("install", "-r", "example.apk")
 
 
-def test_invalid_device(mock_tools, capsys):
+def test_invalid_device(adb, capsys):
     """Invoking `install_apk()` on an invalid device raises an error."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(side_effect=InvalidDeviceError("device", "exampleDevice"))
 
     # Invoke install
diff --git a/tests/integrations/android_sdk/ADB/test_kill.py b/tests/integrations/android_sdk/ADB/test_kill.py
index f647ad13..81b15b83 100644
--- a/tests/integrations/android_sdk/ADB/test_kill.py
+++ b/tests/integrations/android_sdk/ADB/test_kill.py
@@ -5,14 +5,10 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.android_sdk import ADB
 
 
-def test_kill(mock_tools):
+def test_kill(mock_tools, adb):
     """An emulator can be killed."""
-    # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
-
     # Invoke kill
     adb.kill()
 
@@ -29,10 +25,9 @@ def test_kill(mock_tools):
     )
 
 
-def test_kill_failure(mock_tools):
+def test_kill_failure(adb):
     """If emu kill fails, the error is caught."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(
         side_effect=subprocess.CalledProcessError(returncode=1, cmd="adb emu kill")
     )
diff --git a/tests/integrations/android_sdk/ADB/test_logcat.py b/tests/integrations/android_sdk/ADB/test_logcat.py
index 07c85a25..0ec09a81 100644
--- a/tests/integrations/android_sdk/ADB/test_logcat.py
+++ b/tests/integrations/android_sdk/ADB/test_logcat.py
@@ -2,14 +2,9 @@ import os
 import subprocess
 from unittest import mock
 
-from briefcase.integrations.android_sdk import ADB
 
-
-def test_logcat(mock_tools):
+def test_logcat(mock_tools, adb):
     """Invoking `logcat()` calls `Popen()` with the appropriate parameters."""
-    # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
-
     # Mock the result of calling Popen so we can compare against this return value
     popen = mock.MagicMock()
     mock_tools.subprocess.Popen.return_value = popen
diff --git a/tests/integrations/android_sdk/ADB/test_logcat_tail.py b/tests/integrations/android_sdk/ADB/test_logcat_tail.py
index e5194516..61854241 100644
--- a/tests/integrations/android_sdk/ADB/test_logcat_tail.py
+++ b/tests/integrations/android_sdk/ADB/test_logcat_tail.py
@@ -6,14 +6,10 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.android_sdk import ADB
 
 
-def test_logcat_tail(mock_tools):
+def test_logcat_tail(mock_tools, adb):
     """Invoking `logcat_tail()` calls `run()` with the appropriate parameters."""
-    # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
-
     # Invoke logcat_tail with a specific timestamp
     adb.logcat_tail(since=datetime(2022, 11, 10, 9, 8, 7))
 
@@ -38,10 +34,9 @@ def test_logcat_tail(mock_tools):
     )
 
 
-def test_adb_failure(mock_tools):
+def test_adb_failure(mock_tools, adb):
     """If adb logcat fails, the error is caught."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     mock_tools.subprocess.run = MagicMock(
         side_effect=subprocess.CalledProcessError(returncode=1, cmd="adb logcat")
     )
diff --git a/tests/integrations/android_sdk/ADB/test_pid_exists.py b/tests/integrations/android_sdk/ADB/test_pid_exists.py
index fc952785..3d5f7fdc 100644
--- a/tests/integrations/android_sdk/ADB/test_pid_exists.py
+++ b/tests/integrations/android_sdk/ADB/test_pid_exists.py
@@ -1,29 +1,27 @@
 import subprocess
 from unittest.mock import Mock
 
-from briefcase.integrations.android_sdk import ADB
 
-
-def test_pid_exists_succeed(mock_tools):
+def test_pid_exists_succeed(adb):
     """adb.pid_exists() can be called on a process that exists."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = Mock(return_value="")
+
     assert adb.pid_exists("1234")
     adb.run.assert_called_once_with("shell", "test", "-e", "/proc/1234")
 
 
-def test_pid_exists_quiet(mock_tools):
+def test_pid_exists_quiet(adb):
     """adb.pid_exists() can be called in quiet mode on a process that exists."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = Mock(return_value="")
+
     assert adb.pid_exists("1234", quiet=True)
     adb.run.assert_called_once_with("shell", "test", "-e", "/proc/1234", quiet=True)
 
 
-def test_pid_does_not_exist(mock_tools):
+def test_pid_does_not_exist(adb):
     """If adb.pid_exists() returns a status code of 1, it is interpreted as the process
     not existing."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = Mock(side_effect=subprocess.CalledProcessError(returncode=1, cmd="test"))
+
     assert not adb.pid_exists("9999") is None
     adb.run.assert_called_once_with("shell", "test", "-e", "/proc/9999")
diff --git a/tests/integrations/android_sdk/ADB/test_pidof.py b/tests/integrations/android_sdk/ADB/test_pidof.py
index 5f96c5e3..bce59d52 100644
--- a/tests/integrations/android_sdk/ADB/test_pidof.py
+++ b/tests/integrations/android_sdk/ADB/test_pidof.py
@@ -1,37 +1,35 @@
 import subprocess
 from unittest.mock import Mock
 
-from briefcase.integrations.android_sdk import ADB
 
-
-def test_pidof_succeed(mock_tools):
+def test_pidof_succeed(adb):
     """adb.pidof() can be called on a process that exists."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = Mock(return_value="5678\n")
+
     assert adb.pidof("com.example") == "5678"
     adb.run.assert_called_once_with("shell", "pidof", "-s", "com.example")
 
 
-def test_pidof_quiet(mock_tools):
+def test_pidof_quiet(adb):
     """adb.pidof() can be called in quiet mode on a process that exists."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = Mock(return_value="5678\n")
+
     assert adb.pidof("com.example", quiet=True) == "5678"
     adb.run.assert_called_once_with("shell", "pidof", "-s", "com.example", quiet=True)
 
 
-def test_pidof_fail_exit_0(mock_tools):
+def test_pidof_fail_exit_0(adb):
     """If adb.pidof() returns a PID of 0, it is interpreted as the process not
     existing."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = Mock(return_value="")
+
     assert adb.pidof("com.example") is None
     adb.run.assert_called_once_with("shell", "pidof", "-s", "com.example")
 
 
-def test_pidof_fail_exit_1(mock_tools):
+def test_pidof_fail_exit_1(adb):
     """If adb.pidof() fails, it is interpreted as the process not existing."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = Mock(side_effect=subprocess.CalledProcessError(1, "adb shell"))
+
     assert adb.pidof("com.example") is None
     adb.run.assert_called_once_with("shell", "pidof", "-s", "com.example")
diff --git a/tests/integrations/android_sdk/ADB/test_run.py b/tests/integrations/android_sdk/ADB/test_run.py
index f22c028e..0a828ebf 100644
--- a/tests/integrations/android_sdk/ADB/test_run.py
+++ b/tests/integrations/android_sdk/ADB/test_run.py
@@ -6,14 +6,10 @@ from pathlib import Path
 import pytest
 
 from briefcase.exceptions import InvalidDeviceError
-from briefcase.integrations.android_sdk import ADB
 
 
-def test_simple_command(mock_tools, tmp_path):
+def test_simple_command(mock_tools, adb, tmp_path):
     """ADB.run() invokes adb with the provided arguments."""
-    # Create an ADB instance and invoke command()
-    adb = ADB(mock_tools, "exampleDevice")
-
     adb.run("example", "command")
 
     # Check that adb was invoked with the expected commands
@@ -34,11 +30,8 @@ def test_simple_command(mock_tools, tmp_path):
     )
 
 
-def test_quiet_command(mock_tools, tmp_path):
+def test_quiet_command(mock_tools, adb, tmp_path):
     """ADB.run() can be invoked in quiet mode."""
-    # Create an ADB instance and invoke command()
-    adb = ADB(mock_tools, "exampleDevice")
-
     adb.run("example", "command", quiet=True)
 
     # Check that adb was invoked with the expected commands
@@ -70,7 +63,7 @@ def test_quiet_command(mock_tools, tmp_path):
         ("arbitrary-adb-error-unknown-command", subprocess.CalledProcessError),
     ],
 )
-def test_error_handling(mock_tools, tmp_path, name, exception):
+def test_error_handling(mock_tools, adb, name, exception, tmp_path):
     """ADB.run() can parse errors returned by adb."""
     # Set up a mock command with a subprocess module that has with sample data loaded.
     adb_samples = Path(__file__).parent / "adb_errors"
@@ -86,8 +79,7 @@ def test_error_handling(mock_tools, tmp_path, name, exception):
                 )
             )
 
-    # Create an ADB instance and invoke run()
-    adb = ADB(mock_tools, "exampleDevice")
+    # invoke run()
     with pytest.raises(exception):
         adb.run("example", "command")
 
diff --git a/tests/integrations/android_sdk/ADB/test_start_app.py b/tests/integrations/android_sdk/ADB/test_start_app.py
index 52bb2e43..17788a0d 100644
--- a/tests/integrations/android_sdk/ADB/test_start_app.py
+++ b/tests/integrations/android_sdk/ADB/test_start_app.py
@@ -6,7 +6,6 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError, InvalidDeviceError
-from briefcase.integrations.android_sdk import ADB
 
 
 @pytest.mark.parametrize(
@@ -24,10 +23,9 @@ from briefcase.integrations.android_sdk import ADB
         ["one", "two", "three"],
     ],
 )
-def test_start_app_launches_app(mock_tools, capsys, passthrough):
+def test_start_app_launches_app(adb, capsys, passthrough):
     """Invoking `start_app()` calls `run()` with the appropriate parameters."""
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(return_value="example normal adb output")
 
     # Invoke start_app
@@ -56,11 +54,10 @@ def test_start_app_launches_app(mock_tools, capsys, passthrough):
     assert "normal adb output" not in capsys.readouterr()
 
 
-def test_missing_activity(mock_tools):
+def test_missing_activity(adb):
     """If the activity doesn't exist, the error is caught."""
     # Use real `adb` output from launching an activity that does not exist.
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(
         return_value="""\
 Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.\
@@ -77,20 +74,18 @@ MainActivity} does not exist.
     assert "Activity class not found" in str(exc_info.value)
 
 
-def test_invalid_device(mock_tools):
+def test_invalid_device(adb):
     """If the device doesn't exist, the error is caught."""
     # Use real `adb` output from launching an activity that does not exist.
     # Mock out the run command on an adb instance
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(side_effect=InvalidDeviceError("device", "exampleDevice"))
 
     with pytest.raises(InvalidDeviceError):
         adb.start_app("com.example.sample.package", "com.example.sample.activity", [])
 
 
-def test_unable_to_start(mock_tools):
+def test_unable_to_start(adb):
     """If the adb calls for other reasons, the error is caught."""
-    adb = ADB(mock_tools, "exampleDevice")
     adb.run = MagicMock(side_effect=CalledProcessError(cmd=["adb"], returncode=1))
 
     with pytest.raises(
diff --git a/tests/integrations/android_sdk/conftest.py b/tests/integrations/android_sdk/conftest.py
index a64626d2..c56b4859 100644
--- a/tests/integrations/android_sdk/conftest.py
+++ b/tests/integrations/android_sdk/conftest.py
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
-from briefcase.integrations.android_sdk import AndroidSDK
+from briefcase.integrations.android_sdk import ADB, AndroidSDK
 from briefcase.integrations.base import ToolCache
 from briefcase.integrations.download import Download
 from briefcase.integrations.java import JDK
@@ -30,3 +30,8 @@ def android_sdk(mock_tools, tmp_path) -> AndroidSDK:
     sdk_root.mkdir(parents=True)
 
     return AndroidSDK(mock_tools, root_path=sdk_root)
+
+
+@pytest.fixture
+def adb(mock_tools) -> ADB:
+    return ADB(mock_tools, "exampleDevice")
diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index 6ae9e01a..bc83f5df 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -356,15 +356,21 @@ def test_run_crash_at_start(run_command, first_app_config, monkeypatch):
     monkeypatch.setattr(time, "sleep", mock.MagicMock())
 
     # It's the eternal september...
-    start_datetime = datetime.datetime(2022, 9, 8, 7, 6, 42)
+    device_start_datetime = datetime.datetime(2022, 9, 9, 7, 6, 42)
+    pid_lookup_start_datetime = datetime.datetime(2022, 9, 8, 7, 6, 42)
+
+    # Mock time progression so PID lookup loops 5 times
     mock_datetime = mock.MagicMock(spec=datetime.datetime)
     # Mock datetime.now() at +0s, +0.5s, +1.5s,... +5.5s
     mock_datetime.now.side_effect = [
-        start_datetime + datetime.timedelta(seconds=delay)
+        pid_lookup_start_datetime + datetime.timedelta(seconds=delay)
         for delay in [0] + [x + 0.5 for x in range(0, 6)]
     ]
     monkeypatch.setattr(datetime, "datetime", mock_datetime)
 
+    # Mock the device start datetime
+    run_command.tools.mock_adb.datetime.return_value = device_start_datetime
+
     with pytest.raises(
         BriefcaseCommandError, match=r"Problem starting app 'first-app'"
     ):
@@ -386,7 +392,7 @@ def test_run_crash_at_start(run_command, first_app_config, monkeypatch):
 
     # But we will get a log dump from logcat_tail
     run_command.tools.mock_adb.logcat_tail.assert_called_once_with(
-        since=start_datetime - datetime.timedelta(seconds=10)
+        since=device_start_datetime
     )