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."); + } +}