OboeTester: Use AlarmManager to schedule timeouts so they work in deep sleep (#2290)

diff --git a/apps/OboeTester/app/build.gradle b/apps/OboeTester/app/build.gradle
index 42fba54..582ee81 100644
--- a/apps/OboeTester/app/build.gradle
+++ b/apps/OboeTester/app/build.gradle
@@ -38,6 +38,7 @@
     implementation "androidx.core:core-ktx:1.9.0"
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.appcompat:appcompat:1.6.1'
+    implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
 
     androidTestImplementation 'androidx.test.ext:junit:1.1.5'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
diff --git a/apps/OboeTester/app/src/main/AndroidManifest.xml b/apps/OboeTester/app/src/main/AndroidManifest.xml
index 5a4ea29..d791f4d 100644
--- a/apps/OboeTester/app/src/main/AndroidManifest.xml
+++ b/apps/OboeTester/app/src/main/AndroidManifest.xml
@@ -28,6 +28,7 @@
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
     <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
+    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
 
     <application
         android:icon="@mipmap/ic_launcher"
@@ -177,6 +178,9 @@
                 android:name="android.support.FILE_PROVIDER_PATHS"
                 android:resource="@xml/provider_paths" />
         </provider>
+
+        <receiver android:name=".TestTimeoutReceiver" android:exported="false"/>
+
     </application>
 
 </manifest>
diff --git a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/DynamicWorkloadActivity.java b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/DynamicWorkloadActivity.java
index 0c7a361..67d2375 100644
--- a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/DynamicWorkloadActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/DynamicWorkloadActivity.java
@@ -490,17 +490,7 @@
                 mDrawAlwaysBox.setChecked(mDrawChartAlways);
             });
 
-            int durationSeconds = IntentBasedTestSupport.getDurationSeconds(mBundleFromIntent);
-            if (durationSeconds > 0) {
-                // Schedule the end of the test.
-                Handler handler = new Handler(Looper.getMainLooper()); // UI thread
-                handler.postDelayed(new Runnable() {
-                    @Override
-                    public void run() {
-                        stopAutomaticTest();
-                    }
-                }, durationSeconds * 1000);
-            }
+            
         } catch (Exception e) {
             showErrorToast(e.getMessage());
         } finally {
@@ -508,7 +498,8 @@
         }
     }
 
-    void stopAutomaticTest() {
+    @Override
+    public void stopAutomaticTest() {
         String report = getCommonTestReport();
         AudioStreamBase outputStream =mAudioOutTester.getCurrentAudioStream();
         report += "out.xruns = " + outputStream.getXRunCount() + "\n";
diff --git a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/ManualGlitchActivity.java b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/ManualGlitchActivity.java
index 9281cd5..4bd6402 100644
--- a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/ManualGlitchActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/ManualGlitchActivity.java
@@ -199,23 +199,12 @@
     @Override
     public void startTestUsingBundle() {
         configureStreamsFromBundle(mBundleFromIntent);
-
-        int durationSeconds = IntentBasedTestSupport.getDurationSeconds(mBundleFromIntent);
         int numBursts = mBundleFromIntent.getInt(KEY_BUFFER_BURSTS, VALUE_DEFAULT_BUFFER_BURSTS);
 
         try {
             openStartAudioTestUI();
             int sizeFrames = mAudioOutTester.getCurrentAudioStream().getFramesPerBurst() * numBursts;
             mAudioOutTester.getCurrentAudioStream().setBufferSizeInFrames(sizeFrames);
-
-            // Schedule the end of the test.
-            Handler handler = new Handler(Looper.getMainLooper()); // UI thread
-            handler.postDelayed(new Runnable() {
-                @Override
-                public void run() {
-                    stopAutomaticTest();
-                }
-            }, durationSeconds * 1000);
         } catch (IOException e) {
             String report = "Open failed: " + e.getMessage();
             maybeWriteTestResult(report);
@@ -226,7 +215,8 @@
 
     }
 
-    void stopAutomaticTest() {
+    @Override
+    public void stopAutomaticTest() {
         String report = getCommonTestReport()
                 + String.format(Locale.getDefault(), "tolerance = %5.3f\n", mTolerance)
                 + mLastGlitchReport;
diff --git a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestAudioActivity.java b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestAudioActivity.java
index e4c39f4..e1677b8 100644
--- a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestAudioActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestAudioActivity.java
@@ -21,8 +21,10 @@
 import static com.mobileer.oboetester.IntentBasedTestSupport.KEY_RESTART_STREAM_IF_CLOSED;
 import static com.mobileer.oboetester.StreamConfiguration.convertErrorToText;
 
+import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.ServiceInfo;
@@ -44,6 +46,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.AppCompatActivity;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 
 import java.io.File;
 import java.io.IOException;
@@ -120,6 +123,16 @@
     private String mTestResults;
     private ExternalFileWriter mExternalFileWriter = new ExternalFileWriter(this);
 
+    private TestTimeoutScheduler mTestTimeoutScheduler = new TestTimeoutScheduler();
+    private BroadcastReceiver mStopTestReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (mTestRunningByIntent) {
+                stopAutomaticTest();
+            }
+        }
+    };
+
     public String getTestName() {
         return "TestAudio";
     }
@@ -318,6 +331,8 @@
     @Override
     public void onResume() {
         super.onResume();
+        LocalBroadcastManager.getInstance(this).registerReceiver(mStopTestReceiver,
+                new IntentFilter(TestTimeoutReceiver.ACTION_STOP_TEST));
         if (mBundleFromIntent != null) {
             processBundleFromIntent();
         }
@@ -355,6 +370,12 @@
                 mRestartStreamIfClosed = mBundleFromIntent.getBoolean(KEY_RESTART_STREAM_IF_CLOSED,
                         false);
                 setVolumeFromIntent();
+
+                int durationSeconds = IntentBasedTestSupport.getDurationSeconds(mBundleFromIntent);
+                if (durationSeconds > 0) {
+                    mTestTimeoutScheduler.scheduleTestTimeout(TestAudioActivity.this, durationSeconds);
+                }
+
                 startTestUsingBundle();
             } catch( Exception e) {
                 showErrorToast(e.getMessage());
@@ -365,9 +386,14 @@
     public void startTestUsingBundle() {
     }
 
+    public void stopAutomaticTest() {
+
+    }
+
     @Override
     protected void onPause() {
         super.onPause();
+        LocalBroadcastManager.getInstance(this).unregisterReceiver(mStopTestReceiver);
     }
 
     @Override
@@ -870,6 +896,9 @@
 
     // This should only be called from UI events such as onStop or a button press.
     public void onStopTest() {
+        if (mTestRunningByIntent) {
+            mTestTimeoutScheduler.cancelTestTimeout(this);
+        }
         stopTest();
     }
 
diff --git a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestInputActivity.java b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestInputActivity.java
index f2d328e..56f3e0b 100644
--- a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestInputActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestInputActivity.java
@@ -204,18 +204,6 @@
 
             openAudio();
             startAudio();
-
-            int durationSeconds = IntentBasedTestSupport.getDurationSeconds(mBundleFromIntent);
-            if (durationSeconds > 0) {
-                // Schedule the end of the test.
-                Handler handler = new Handler(Looper.getMainLooper()); // UI thread
-                handler.postDelayed(new Runnable() {
-                    @Override
-                    public void run() {
-                        stopAutomaticTest();
-                    }
-                }, durationSeconds * 1000);
-            }
         } catch (Exception e) {
             showErrorToast(e.getMessage());
         } finally {
@@ -223,7 +211,8 @@
         }
     }
 
-    void stopAutomaticTest() {
+    @Override
+    public void stopAutomaticTest() {
         String report = getCommonTestReport();
         stopAudio();
         maybeWriteTestResult(report);
diff --git a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestOutputActivity.java b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestOutputActivity.java
index 758b25f..da1110c 100644
--- a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestOutputActivity.java
+++ b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestOutputActivity.java
@@ -321,18 +321,6 @@
                 }
             }
             startAudio();
-
-            int durationSeconds = IntentBasedTestSupport.getDurationSeconds(mBundleFromIntent);
-            if (durationSeconds > 0) {
-                // Schedule the end of the test.
-                Handler handler = new Handler(Looper.getMainLooper()); // UI thread
-                handler.postDelayed(new Runnable() {
-                    @Override
-                    public void run() {
-                        stopAutomaticTest();
-                    }
-                }, durationSeconds * 1000);
-            }
         } catch (Exception e) {
             showErrorToast(e.getMessage());
         } finally {
@@ -340,7 +328,8 @@
         }
     }
 
-    void stopAutomaticTest() {
+    @Override
+    public void stopAutomaticTest() {
         String report = getCommonTestReport();
         stopAudio();
         maybeWriteTestResult(report);
diff --git a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestTimeoutReceiver.java b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestTimeoutReceiver.java
new file mode 100644
index 0000000..d6722ee
--- /dev/null
+++ b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestTimeoutReceiver.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mobileer.oboetester;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+public class TestTimeoutReceiver extends BroadcastReceiver {
+    private static final String TAG = "TestTimeoutReceiver";
+    public static final String ACTION_STOP_TEST = "com.mobileer.oboetester.ACTION_STOP_TEST";
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        Log.d(TAG, "Alarm received. Stopping test via local broadcast.");
+        Intent stopIntent = new Intent(ACTION_STOP_TEST);
+        LocalBroadcastManager.getInstance(context).sendBroadcast(stopIntent);
+    }
+}
diff --git a/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestTimeoutScheduler.java b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestTimeoutScheduler.java
new file mode 100644
index 0000000..d77a8be
--- /dev/null
+++ b/apps/OboeTester/app/src/main/java/com/mobileer/oboetester/TestTimeoutScheduler.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mobileer.oboetester;
+
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.SystemClock;
+import android.provider.Settings;
+import android.util.Log;
+import android.widget.Toast;
+
+public class TestTimeoutScheduler {
+
+    private static final int REQUEST_CODE = 54321;
+    private static final String TAG = "TestTimeoutScheduler";
+
+    public void scheduleTestTimeout(Context context, int durationSeconds) {
+        Log.d(TAG, "Attempting to schedule test timeout.");
+        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+        if (alarmManager == null) {
+            Log.e(TAG, "AlarmManager is null.");
+            return;
+        }
+
+        // On Android 12 (S) and higher, we need to check for the permission.
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            if (!alarmManager.canScheduleExactAlarms()) {
+                Log.w(TAG, "Missing SCHEDULE_EXACT_ALARM permission.");
+                Toast.makeText(context, "Permission needed to schedule test timeout", Toast.LENGTH_LONG).show();
+                // Guide the user to the settings screen to grant the permission.
+                Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
+                context.startActivity(intent);
+                return; // Stop execution until the permission is granted.
+            }
+        }
+
+        Intent intent = new Intent(context, TestTimeoutReceiver.class);
+        PendingIntent pendingIntent = PendingIntent.getBroadcast(
+                context,
+                REQUEST_CODE,
+                intent,
+                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+        );
+
+        long triggerAtMillis = SystemClock.elapsedRealtime() + durationSeconds * 1000L;
+
+        try {
+            alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtMillis, pendingIntent);
+            Log.i(TAG, "Exact alarm scheduled successfully for " + durationSeconds + " seconds.");
+        } catch (SecurityException se) {
+            // This catch is now a fallback, as the check above should prevent this.
+            Log.e(TAG, "SecurityException while setting alarm. This should not happen after the check.", se);
+        }
+    }
+
+    public void cancelTestTimeout(Context context) {
+        AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+        if (alarmManager == null) {
+            Log.e(TAG, "AlarmManager is null when trying to cancel.");
+            return;
+        }
+
+        Intent intent = new Intent(context, TestTimeoutReceiver.class);
+        PendingIntent pendingIntent = PendingIntent.getBroadcast(
+                context,
+                REQUEST_CODE,
+                intent,
+                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+        );
+
+        alarmManager.cancel(pendingIntent);
+        Log.i(TAG, "Canceled test timeout alarm.");
+    }
+}