From ea0f4b65f5691889f187b64ccd15a08a0ac07263 Mon Sep 17 00:00:00 2001 From: Vince Jankovics Date: Wed, 23 Sep 2026 19:08:27 +0100 Subject: [PATCH 1/4] Keep measuring in the background with a foreground service A new setting, off by default: Keep measuring in the background (Advanced, next to the proximity lock). With it on, startMeasurement starts MeasurementService while the activity is visible, which Android requires for the location and microphone types. The service declares specialUse plus location, microphone and connectedDevice and requests only the types the loaded experiment needs; it holds a partial wake lock (a foreground service alone does not keep the CPU awake with the screen off) and shows an ongoing notification whose tap returns to the existing task and whose Stop action ends the run. Swiping the task away ends it too. Experiment.onStop keeps inputs, connections and the analysis thread running when the service owns the run and the activity is not finishing; only the view loop rests, and the depth camera closes as the system would revoke it anyway. onRestart resumes the loop, onDestroy is the real teardown, so recreation (locale, dark mode, split screen) still stops a run as before. Only the owning activity can stop the service, so a second experiment screen closing over a background run cannot end it. Camera experiments still pause in the background. Switching the setting on requests POST_NOTIFICATIONS on Android 13+; without it the run still continues, listed in the system's task manager. LifecycleTest gains a test with the setting on (home, still measuring, return, still measuring); the existing test keeps pinning the default. t1_instrumented.sh grants the notification permission. --- .../de/rwth_aachen/phyphox/LifecycleTest.java | 66 ++++++ app/src/main/AndroidManifest.xml | 16 ++ .../de/rwth_aachen/phyphox/Experiment.java | 30 ++- .../phyphox/MeasurementService.java | 189 ++++++++++++++++++ .../SettingsActivity/SettingsFragment.java | 20 ++ app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/settings.xml | 7 + tools/t1_instrumented.sh | 1 + 8 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java diff --git a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java index f0181ee7..b6c5a139 100644 --- a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java +++ b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java @@ -5,7 +5,13 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; +import android.app.Activity; +import android.content.Context; +import android.os.Build; + import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.Stage; import androidx.test.uiautomator.By; import androidx.test.uiautomator.UiDevice; import androidx.test.uiautomator.Until; @@ -34,16 +40,51 @@ public class LifecycleTest { public void enableRemoteApi() throws Exception { shell("setprop debug.phyphox.remote 1"); shell("setprop debug.phyphox.remotePort " + PORT); + setBackgroundMeasurement(false); + //The foreground service's notification (Android 13+); the run continues without it, but the test should look like a real device + if (Build.VERSION.SDK_INT >= 33) + shell("pm grant " + getInstrumentation().getTargetContext().getPackageName() + " android.permission.POST_NOTIFICATIONS"); } @After public void cleanUp() throws Exception { FixtureExperiment.close(FixtureExperiment.activity()); + setBackgroundMeasurement(false); device().setOrientationNatural(); shell("setprop debug.phyphox.remote '\"\"'"); shell("setprop debug.phyphox.remotePort '\"\"'"); } + //The "keep measuring in the background" setting, read when an experiment loads + private void setBackgroundMeasurement(boolean on) { + Context app = getInstrumentation().getTargetContext(); + androidx.preference.PreferenceManager.getDefaultSharedPreferences(app) + .edit() + .putBoolean("backgroundMeasurement", on) + .commit(); + } + + //The live experiment activity in whatever lifecycle stage, so its state can be read while it is stopped in the + //background. Activities that earlier tests finished linger in the DESTROYED stage and must not be picked. + private Experiment experimentActivityInAnyStage() { + final Experiment[] holder = new Experiment[1]; + getInstrumentation().runOnMainSync(() -> { + for (Stage stage : new Stage[]{Stage.RESUMED, Stage.PAUSED, Stage.STOPPED, Stage.RESTARTED, Stage.STARTED, Stage.CREATED}) + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(stage)) + if (activity instanceof Experiment && holder[0] == null) + holder[0] = (Experiment) activity; + }); + return holder[0]; + } + + private boolean activityMeasuring() { + Experiment activity = experimentActivityInAnyStage(); + assertTrue("no experiment activity alive", activity != null); + final boolean[] holder = new boolean[1]; + getInstrumentation().runOnMainSync(() -> holder[0] = activity.measuring); + return holder[0]; + } + private UiDevice device() { return UiDevice.getInstance(getInstrumentation()); } @@ -145,6 +186,31 @@ public void theBackgroundStopsTheRunCleanlyAndItCanResume() throws Exception { awaitMeasuring(false); } + @Test + public void withTheSettingOnTheRunContinuesInTheBackground() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + + device().pressHome(); + Thread.sleep(3000); + //The remote server rests in the background, so the stopped activity is asked directly + assertTrue("the measurement stopped in the background", activityMeasuring()); + assertTrue("the foreground service is not running", MeasurementService.isRunningFor(experimentActivityInAnyStage())); + + FixtureExperiment.bringToForeground(); + Thread.sleep(2000); + awaitApi(); + assertTrue("the measurement did not survive the return to the foreground", measuring()); + + api("/control?cmd=stop"); + awaitMeasuring(false); + assertEquals("the foreground service outlived the run", false, MeasurementService.isRunningFor(experimentActivityInAnyStage())); + } + @Test public void aSecondExperimentReplacesTheFirst() throws Exception { assumeTrue(FixtureExperiment.available(FIXTURE)); diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf269563..5383c86c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,13 @@ + + + + + + + @@ -121,6 +128,15 @@ android:name="autoStoreLocales" android:value="true" /> + + + + diff --git a/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java b/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java index 2829661f..1ba8ab82 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java @@ -166,6 +166,8 @@ public class Experiment extends AppCompatActivity implements View.OnClickListene float titleDefaultTextSize = 0f; //Size of the title before it has been fitted to the toolbar int titleFittedForWidth = -1; //Toolbar width the title has last been fitted to boolean shutdown = false; //The activity should be stopped. Used to escape the measurement loop. + boolean viewsPaused = false; //The activity is stopped while the measurement continues in the background (see MeasurementService); the view loop rests until onRestart + boolean backgroundMeasurement = false; //Setting: keep a running measurement alive while the screen is not visible boolean beforeStart = true; //Experiment has not yet been started even once boolean menuHintDismissed = false; //Remember that the user has clicked away the hint to the menu boolean startHintDismissed = false; //Remember that the user has clicked away the hint to the start button @@ -399,7 +401,15 @@ public void onStop() { } stopRemoteServer(); //Remote server should stop when the app is not active - shutdownIO(); + if (measuring && backgroundMeasurement && MeasurementService.isRunningFor(this) && !isFinishing()) { + //The run goes on under the foreground service (see MeasurementService): inputs, connections and the + //analysis thread stay as they are, only the view loop rests. The depth camera closes as the system + //would revoke it in the background anyway; onRestart reopens it. + viewsPaused = true; + if (experiment != null && experiment.depthInput != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + experiment.depthInput.stopCameras(); + } else + shutdownIO(); if (popupWindow != null) popupWindow.dismiss(); @@ -414,6 +424,7 @@ public void onRestart() { super.onRestart(); shutdown = false; //Deactivate shutdown variable + viewsPaused = false; //Back on screen: the view loop picks up a run that continued in the background if (experiment != null && experiment.depthInput != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { try { @@ -431,6 +442,14 @@ public void onRestart() { invalidateOptionsMenu(); } + @Override + //A run that continued in the background ends with the activity that owns it. Recreation (locale, dark + //mode, split screen) rebuilds the UI from app.experiment without the measuring state, so it stops the run as before. + protected void onDestroy() { + shutdownIO(); + super.onDestroy(); + } + @Override //Config changes declared in the manifest do not recreate the activity and do not affect our UI public void onConfigurationChanged(Configuration newConfig) { @@ -863,6 +882,7 @@ else if (!startHintDismissed) settings = PreferenceManager.getDefaultSharedPreferences(this); proximityLock = settings.getBoolean("proximityLock", false); + backgroundMeasurement = settings.getBoolean("backgroundMeasurement", false); if (proximityLock) { PowerManager powerManager = (PowerManager) getBaseContext().getSystemService(Context.POWER_SERVICE); if (powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) @@ -1645,7 +1665,7 @@ public void run() { Log.e("updateViews", "Unhandled exception.", e); } finally { //If we are not supposed to stop, let's do it again in a short while - if (!shutdown) { + if (!shutdown && !viewsPaused) { if (measuring) updateViewsHandler.postDelayed(this, 40); else @@ -1748,6 +1768,11 @@ public void run() { //No more turning off during the measurement setKeepScreenOn(true); + //With the setting on, a foreground service keeps the run alive when the screen goes away. It has to + //start now, while the activity is visible, or Android refuses it the location and microphone types. + if (backgroundMeasurement) + MeasurementService.start(this, experiment); + //Start the analysis "loop" Thread t = new Thread(updateData); t.start(); @@ -1895,6 +1920,7 @@ public void onFinish() { //Stop the measurement public void stopMeasurement() { measuring = false; //Set the state + MeasurementService.stop(this); //Ends the background run's service and notification; a no-op without them //Lift the restrictions, so the screen may turn off again and the user may rotate the device (unless remote server is active) if (!serverEnabled) { diff --git a/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java b/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java new file mode 100644 index 00000000..0b9e9ff7 --- /dev/null +++ b/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java @@ -0,0 +1,189 @@ +package de.rwth_aachen.phyphox; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; + +import java.lang.ref.WeakReference; + +//Keeps a running measurement alive while the experiment screen is not visible. Android delivers sensor, +//location and microphone data to an app in the background only through a foreground service: a Service +//pinned by a persistent notification that declares the kinds of data it uses. The service itself does no +//measuring. The Experiment activity survives in the background (stopped, not destroyed), keeps its inputs +//and analysis thread running and only rests its view loop. The service adds the notification and a partial +//wake lock, because a foreground service alone does not keep the CPU awake once the screen turns off. +public class MeasurementService extends Service { + private static final String TAG = "MeasurementService"; + private static final String CHANNEL_ID = "measurement"; + private static final int NOTIFICATION_ID = 1; + private static final String EXTRA_TITLE = "title"; + private static final String EXTRA_TYPES = "types"; + private static final String ACTION_STOP = "de.rwth_aachen.phyphox.action.STOP_MEASUREMENT"; + + //The activity that owns the running measurement, so the notification's stop action and a swiped-away task can end it + private static WeakReference owner = new WeakReference<>(null); + private static boolean running = false; + + private PowerManager.WakeLock wakeLock = null; + + //The foreground service types the experiment needs. Without the matching type Android cuts a background + //service off from location, the microphone or connected devices. The constants are plain ints, and + //ServiceCompat drops the ones an older Android does not know. + @SuppressLint("InlinedApi") + static int typesFor(PhyphoxExperiment experiment) { + int types = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE; //Plain sensors have no type of their own + if (experiment.gpsIn != null) + types |= ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION; + if (experiment.audioRecord != null) + types |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; + if (!experiment.bluetoothInputs.isEmpty() || !experiment.bluetoothOutputs.isEmpty()) + types |= ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE; + return types; + } + + //Called when a measurement starts while the activity is visible, which the location and microphone types require + static void start(Experiment activity, PhyphoxExperiment experiment) { + owner = new WeakReference<>(activity); + Intent intent = new Intent(activity, MeasurementService.class); + intent.putExtra(EXTRA_TITLE, experiment.title); + intent.putExtra(EXTRA_TYPES, typesFor(experiment)); + try { + ContextCompat.startForegroundService(activity, intent); + running = true; + } catch (Exception e) { + //Android 12+ refuses starts from the background. The run then only lasts while the screen is visible, as without the setting. + Log.e(TAG, "Could not start the foreground service: " + e.getMessage()); + running = false; + } + } + + //Called by the activity whose measurement ends. Another Experiment activity (a second experiment opened over + //a run that continues in the background) must not end a service it does not own. + static void stop(Experiment activity) { + Experiment current = owner.get(); + if (current != null && current != activity) + return; + end(activity); + } + + private static void end(Context context) { + running = false; + owner.clear(); + context.stopService(new Intent(context, MeasurementService.class)); + } + + //True between start and stop for the activity that started it, i.e. while its running measurement may continue in the background + static boolean isRunningFor(Experiment activity) { + return running && owner.get() == activity; + } + + @Override + public void onCreate() { + super.onCreate(); + NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && manager != null) { + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, getString(R.string.backgroundMeasurementChannel), NotificationManager.IMPORTANCE_LOW); + manager.createNotificationChannel(channel); + } + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (powerManager != null) + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "phyphox:measurement"); + } + + @SuppressLint({"WakelockTimeout", "InlinedApi"}) //A measurement has no bounded duration; the lock ends with the service + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null && ACTION_STOP.equals(intent.getAction())) { + stopMeasurement(); + return START_NOT_STICKY; + } + String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null; + int types = intent != null ? intent.getIntExtra(EXTRA_TYPES, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) : ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE; + try { + ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(title), types); + } catch (Exception e) { + //A type whose prerequisite is not met (for example location services turned off) is refused with a + //SecurityException. The run then only lasts while the screen is visible, as without the setting. + Log.e(TAG, "Could not promote the service to the foreground: " + e.getMessage()); + running = false; + stopSelf(); + return START_NOT_STICKY; + } + if (wakeLock != null && !wakeLock.isHeld()) + wakeLock.acquire(); + return START_NOT_STICKY; + } + + //The notification's stop action and a swiped-away task end the measurement through its owner; without an owner only the service ends + private void stopMeasurement() { + Experiment activity = owner.get(); + if (activity != null) + activity.stopMeasurement(); //Stops this service as well + else + end(this); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + stopMeasurement(); + super.onTaskRemoved(rootIntent); + } + + @Override + public void onDestroy() { + if (wakeLock != null && wakeLock.isHeld()) + wakeLock.release(); + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); + running = false; + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private Notification buildNotification(String title) { + int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + //Tapping returns to the experiment the way the launcher icon does: the existing task comes to the + //front without creating a second Experiment activity (which would start a second remote server) + Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName()); + PendingIntent open = null; + if (launch != null) { + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + open = PendingIntent.getActivity(this, 0, launch, flags); + } + Intent stopIntent = new Intent(this, MeasurementService.class).setAction(ACTION_STOP); + PendingIntent stop = PendingIntent.getService(this, 1, stopIntent, flags); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_phyphox_transparent) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.backgroundMeasurementNotification, title != null ? title : "")) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(0, getString(R.string.stop), stop); + if (open != null) + builder.setContentIntent(open); + return builder.build(); + } +} diff --git a/app/src/main/java/de/rwth_aachen/phyphox/SettingsActivity/SettingsFragment.java b/app/src/main/java/de/rwth_aachen/phyphox/SettingsActivity/SettingsFragment.java index e584f266..d414965a 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/SettingsActivity/SettingsFragment.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/SettingsActivity/SettingsFragment.java @@ -1,18 +1,23 @@ package de.rwth_aachen.phyphox.SettingsActivity; +import android.Manifest; +import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; import android.text.InputType; import android.widget.Toast; import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; import androidx.core.os.LocaleListCompat; import androidx.preference.EditTextPreference; import androidx.preference.ListPreference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import androidx.preference.SeekBarPreference; +import androidx.preference.SwitchPreference; import java.util.ArrayList; import java.util.Arrays; @@ -39,6 +44,7 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { setupPortEditText(); setupFileNameFormatEditText(); + setupBackgroundMeasurement(); prepareLanguageList(); updateCurrentLanguage(); updateTheme(); @@ -57,6 +63,20 @@ private void setupUnitSystem() { }); } + //Android 13+ shows the notification of a measurement running in the background only with this permission. + //Without it the run still continues, listed only in the system's task manager, so the switch stays on either way. + private void setupBackgroundMeasurement() { + SwitchPreference preference = findPreference("backgroundMeasurement"); + if (preference == null) + return; + preference.setOnPreferenceChangeListener((pref, newValue) -> { + if (Boolean.TRUE.equals(newValue) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) + ActivityCompat.requestPermissions(requireActivity(), new String[]{Manifest.permission.POST_NOTIFICATIONS}, 0); + return true; + }); + } + private void setupPortEditText() { EditTextPreference editTextPreference = findPreference("remoteAccessPort"); if (editTextPreference != null) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a5a228eb..7eb8dc13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -378,6 +378,10 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND Advanced Proximity lock Disable the screen when the proximity sensor is triggered to avoid unintended inputs for example in a pocket. + Keep measuring in the background + Continue a running measurement when you leave phyphox or turn off the screen. A notification shows while it runs and lets you stop it. Camera experiments still pause. + Running measurement + Measuring in the background: %1$s Remote access port Default file names Template for default file names when exporting data, sharing a screenshot or saving a state. Available placeholders:\n{title} – experiment title\n{date} – current date\n{time} – current time\n{startDate} – date at the start of the measurement\n{startTime} – time at the start of the measurement\n{duration} – measurement duration diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index b79736b4..b3688300 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -86,6 +86,13 @@ android:summary="@string/settingsProximityLockDetail" android:persistent="true" android:defaultValue="false" /> + diff --git a/tools/t1_instrumented.sh b/tools/t1_instrumented.sh index 11a44e77..b8e8a689 100755 --- a/tools/t1_instrumented.sh +++ b/tools/t1_instrumented.sh @@ -79,6 +79,7 @@ fi adb shell pm grant $PACKAGE android.permission.RECORD_AUDIO adb shell pm grant $PACKAGE android.permission.CAMERA adb shell pm grant $PACKAGE android.permission.ACCESS_FINE_LOCATION +adb shell pm grant $PACKAGE android.permission.POST_NOTIFICATIONS LANGUAGE_TITLE="Languages${PHYPHOX_TEST_LANGUAGE_SHARD:+, shard $PHYPHOX_TEST_LANGUAGE_SHARD}" case $SUITES in From d62f95775111ec576a6420450e5a19a7a53bf604 Mon Sep 17 00:00:00 2001 From: Vince Jankovics Date: Wed, 23 Sep 2026 23:04:17 +0100 Subject: [PATCH 2/4] Let a background run outlive its screen and fix the review findings MeasurementRun now owns a measurement: the measuring state, the analysis thread, the timed-run countdowns with their beeps, and the foreground service. The Experiment activity attaches to a run and detaches from it. A run with the service goes on when Android destroys its activity (Recents trimming or a swiped-away task, which the app cannot tell apart, so removing the task no longer stops a background run; the notification's Stop does), and the notification's tap attaches a new Experiment screen to the same experiment and data. A recreated activity (dark mode, language, font size) attaches to the run instead of ending it, with or without the setting. Camera and depth runs still stop and never use the service. One run at a time goes on without its screen: starting a second asks whether to end it. MeasurementService: an end that arrives before startForeground waits for it, as Android kills the app otherwise (a zero-length timed run did). A run that ends while nobody watches (its timer, the notification's Stop, a remote stop) keeps the service with a "Measurement ended in the background" notification until the user opens the result or closes the notification, so the unsaved data is not reclaimed with a cached process; its connections close as leaving the screen would. A swiped-away notification of a running measurement (Android 14+) is posted again. The tap starts Experiment with CLEAR_TOP and SINGLE_TOP instead of the launcher intent, which pushed the experiment list on Android 10 and older and for runs opened from another app. The service's statics are only touched on the main thread, and an old instance's onDestroy no longer resets the state of a newer start. Experiment: the service starts with a timed run's countdown, so leaving during the countdown no longer cancels the run. The remote server keeps serving a background run and the view loop carries out its commands. The proximity lock is released in the background and taken again on return. A remote start queued before shutdownIO no longer starts a run. The countdown refreshes the menu at most every 0.1 s and only on screen. Bluetooth battery and signal information goes to the screen of the device's own experiment (a weak delegate per device instead of a static), and so do a device's commands (a second weak delegate instead of the activity that loaded the device, which may be gone; the commands read the run's state). The local network permission is only requested from a screen in the foreground, where Android shows the request. LifecycleTest reads the service state from dumpsys and the notification from NotificationManager instead of the app's own flags, releases any run in its cleanup, sends the home key through the shell (UiDevice.pressHome waits for an idle screen, which a running countdown never gives) and gains tests for a zero-length timed run, recreation, removal of the task, the notification's return, Stop, Close and swipe, a timed run and a remote stop ending in the background, the countdown in the background, the proximity lock and a second experiment. 16/16 on the API 36 emulator. BluetoothCommandTest checks that commands go to the screen of the run, not the one that loaded the device. --- .../phyphox/BleCompatConnectTest.java | 2 +- .../de/rwth_aachen/phyphox/LifecycleTest.java | 441 ++++++++++++++- .../rwth_aachen/phyphox/ViewBehaviorTest.java | 2 +- .../phyphox/Bluetooth/Bluetooth.kt | 18 +- .../de/rwth_aachen/phyphox/Experiment.java | 514 ++++++++++-------- .../rwth_aachen/phyphox/MeasurementRun.java | 374 +++++++++++++ .../phyphox/MeasurementService.java | 211 ++++--- .../NetworkConnection/NetworkConnection.java | 21 +- .../phyphox/PhyphoxExperiment.java | 17 + .../de/rwth_aachen/phyphox/RemoteServer.java | 4 +- app/src/main/res/values/strings.xml | 5 +- .../phyphox/Bluetooth/BluetoothCommandTest.kt | 15 + 12 files changed, 1280 insertions(+), 344 deletions(-) create mode 100644 app/src/main/java/de/rwth_aachen/phyphox/MeasurementRun.java diff --git a/app/src/androidTest/java/de/rwth_aachen/phyphox/BleCompatConnectTest.java b/app/src/androidTest/java/de/rwth_aachen/phyphox/BleCompatConnectTest.java index b9834190..d19e603e 100644 --- a/app/src/androidTest/java/de/rwth_aachen/phyphox/BleCompatConnectTest.java +++ b/app/src/androidTest/java/de/rwth_aachen/phyphox/BleCompatConnectTest.java @@ -116,7 +116,7 @@ public void theDeviceOffersItsExperimentAndItLoads() throws Exception { experiment.experiment.loaded); assertFalse("the experiment must be left for the host to start, not started here", - experiment.measuring); + experiment.isMeasuring()); //Instrumentation runs in the app's process: returning kills the remote server the host needs. if (holdForHost) diff --git a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java index b6c5a139..84fa5b7c 100644 --- a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java +++ b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java @@ -2,12 +2,20 @@ import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; import android.app.Activity; +import android.app.ActivityManager; +import android.app.Notification; +import android.app.NotificationManager; import android.content.Context; +import android.content.Intent; import android.os.Build; +import android.service.notification.StatusBarNotification; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; @@ -26,16 +34,23 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; // phyphox-test: lifecycle -//A running experiment under rotation, backgrounding, a second experiment and quick start/stop, -//observed through the remote API. +//A running experiment under rotation, recreation, backgrounding, removal of its screen, a second experiment +//and quick start/stop, observed through the remote API, the run itself and the system's service records. @RunWith(AndroidJUnit4.class) public class LifecycleTest { private static final int PORT = 8080; private static final String FIXTURE = "values.phyphox"; + private interface Check { + boolean holds() throws Exception; + } + @Before public void enableRemoteApi() throws Exception { shell("setprop debug.phyphox.remote 1"); @@ -48,7 +63,14 @@ public void enableRemoteApi() throws Exception { @After public void cleanUp() throws Exception { - FixtureExperiment.close(FixtureExperiment.activity()); + //A run may go on without any screen (see MeasurementRun): let it go, service included, before the next test + getInstrumentation().runOnMainSync(() -> { + MeasurementRun run = MeasurementRun.current(); + if (run != null) + run.release(); + }); + for (Experiment activity : experimentActivities()) + FixtureExperiment.close(activity); setBackgroundMeasurement(false); device().setOrientationNatural(); shell("setprop debug.phyphox.remote '\"\"'"); @@ -64,31 +86,143 @@ private void setBackgroundMeasurement(boolean on) { .commit(); } - //The live experiment activity in whatever lifecycle stage, so its state can be read while it is stopped in the - //background. Activities that earlier tests finished linger in the DESTROYED stage and must not be picked. - private Experiment experimentActivityInAnyStage() { - final Experiment[] holder = new Experiment[1]; + //A timed run as the dialog would set it up + private void setTimedRun(Experiment activity, double startDelay, double stopDelay) { getInstrumentation().runOnMainSync(() -> { - for (Stage stage : new Stage[]{Stage.RESUMED, Stage.PAUSED, Stage.STOPPED, Stage.RESTARTED, Stage.STARTED, Stage.CREATED}) + activity.timedRun = true; + activity.timedRunStartDelay = startDelay; + activity.timedRunStopDelay = stopDelay; + }); + } + + @SuppressWarnings("unchecked") + private T onMain(Callable read) { + final Object[] holder = new Object[1]; + getInstrumentation().runOnMainSync(() -> { + try { + holder[0] = read.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + return (T) holder[0]; + } + + //The live experiment activities in whatever lifecycle stage, so their state can be read while they are stopped in + //the background. Activities that earlier tests finished linger in the DESTROYED stage and must not be picked. + private List experimentActivities() { + return onMain(() -> { + List found = new ArrayList<>(); + for (Stage stage : new Stage[]{Stage.RESUMED, Stage.PAUSED, Stage.STOPPED, Stage.RESTARTED, Stage.STARTED, Stage.CREATED, Stage.PRE_ON_CREATE}) for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(stage)) - if (activity instanceof Experiment && holder[0] == null) - holder[0] = (Experiment) activity; + if (activity instanceof Experiment && !found.contains(activity)) + found.add((Experiment) activity); + return found; }); - return holder[0]; } - private boolean activityMeasuring() { - Experiment activity = experimentActivityInAnyStage(); - assertTrue("no experiment activity alive", activity != null); - final boolean[] holder = new boolean[1]; - getInstrumentation().runOnMainSync(() -> holder[0] = activity.measuring); - return holder[0]; + //The run itself, which may be without a screen + private boolean runMeasuring() { + return onMain(() -> MeasurementRun.current() != null && MeasurementRun.current().measuring); + } + + private boolean runEndedUnseen() { + return onMain(() -> MeasurementRun.current() != null && MeasurementRun.current().isFinishedUnseen()); + } + + //The system's record of the service, not the app's own bookkeeping + private String serviceRecord() throws Exception { + return device().executeShellCommand("dumpsys activity services MeasurementService"); + } + + private boolean serviceRunning() throws Exception { + return serviceRecord().contains("ServiceRecord{"); + } + + private boolean serviceInForeground() throws Exception { + String record = serviceRecord(); + return record.contains("ServiceRecord{") && record.contains("isForeground=true"); + } + + private String notificationText() { + NotificationManager manager = getInstrumentation().getTargetContext().getSystemService(NotificationManager.class); + for (StatusBarNotification notification : manager.getActiveNotifications()) + if (notification.getId() == MeasurementService.NOTIFICATION_ID) { + CharSequence text = notification.getNotification().extras.getCharSequence(Notification.EXTRA_TEXT); + return text != null ? text.toString() : null; + } + return null; + } + + //Held now: the "Wake Locks" block of dumpsys power, not its log of earlier ones + private boolean proximityLockHeld() throws Exception { + String power = device().executeShellCommand("dumpsys power"); + int start = power.indexOf("Wake Locks: size="); + if (start < 0) + return false; + int end = power.indexOf("\n\n", start); + return power.substring(start, end < 0 ? power.length() : end).contains("'phyphox:measuring'"); + } + + private Notification.Action notificationAction(int index) { + NotificationManager manager = getInstrumentation().getTargetContext().getSystemService(NotificationManager.class); + for (StatusBarNotification notification : manager.getActiveNotifications()) + if (notification.getId() == MeasurementService.NOTIFICATION_ID) { + Notification.Action[] actions = notification.getNotification().actions; + return actions != null && actions.length > index ? actions[index] : null; + } + return null; + } + + private long notificationPostTime() { + NotificationManager manager = getInstrumentation().getTargetContext().getSystemService(NotificationManager.class); + for (StatusBarNotification notification : manager.getActiveNotifications()) + if (notification.getId() == MeasurementService.NOTIFICATION_ID) + return notification.getPostTime(); + return -1; + } + + private boolean notificationSaysEnded() { + String ended = getInstrumentation().getTargetContext().getString(R.string.backgroundMeasurementEnded, "").trim(); + String text = notificationText(); + return text != null && text.startsWith(ended); + } + + //What the notification does when tapped + private void tapNotification() { + Context app = getInstrumentation().getTargetContext(); + Intent intent = onMain(() -> MeasurementService.returnIntent(app, MeasurementRun.current())); + app.startActivity(intent); + } + + private boolean eventually(Check check) throws Exception { + return eventually(check, 10000); + } + + private boolean eventually(Check check, long timeoutMs) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMs; + while (!check.holds() && System.currentTimeMillis() < deadline) + Thread.sleep(200); + return check.holds(); } private UiDevice device() { return UiDevice.getInstance(getInstrumentation()); } + //UiDevice.pressHome waits for the screen to go idle, which a running countdown (the menu shows it) never does. + //The key event returns at once, so this waits until the experiment screen has stopped. + private void goHome() throws Exception { + shell("input keyevent KEYCODE_HOME"); + assertTrue("the experiment screen did not leave", eventually(() -> onMain(() -> { + for (Stage stage : new Stage[]{Stage.RESUMED, Stage.PAUSED, Stage.STARTED, Stage.RESTARTED}) + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(stage)) + if (activity instanceof Experiment) + return false; + return true; + }))); + } + private void shell(String command) throws Exception { device().executeShellCommand(command); } @@ -98,7 +232,7 @@ private JSONObject api(String path) throws Exception { new URL("http://127.0.0.1:" + PORT + path).openConnection(); connection.setRequestProperty("Connection", "close"); connection.setConnectTimeout(3000); - connection.setReadTimeout(3000); + connection.setReadTimeout(8000); //cmd=start waits for the main thread's verdict (up to 5 s) try (InputStream in = connection.getInputStream()) { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] chunk = new byte[4096]; @@ -163,6 +297,33 @@ public void aRunSurvivesRotation() throws Exception { awaitMeasuring(false); } + //Dark mode, language or font size: configuration changes the activity does not handle itself recreate it + @Test + public void aRunSurvivesTheRecreationOfItsScreen() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + for (boolean background : new boolean[]{false, true}) { + String setting = background ? " (setting on)" : " (setting off)"; + setBackgroundMeasurement(background); + Experiment first = FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + + getInstrumentation().runOnMainSync(first::recreate); + assertTrue("the screen was not recreated" + setting, eventually(() -> { + Experiment current = FixtureExperiment.activity(); + return current != null && current != first; + })); + awaitApi(); + assertTrue("the measurement ended with the recreated screen" + setting, measuring()); + + api("/control?cmd=stop"); + awaitMeasuring(false); + FixtureExperiment.close(FixtureExperiment.activity()); + Thread.sleep(1500); + } + } + @Test public void theBackgroundStopsTheRunCleanlyAndItCanResume() throws Exception { assumeTrue(FixtureExperiment.available(FIXTURE)); @@ -171,7 +332,7 @@ public void theBackgroundStopsTheRunCleanlyAndItCanResume() throws Exception { api("/control?cmd=start"); awaitMeasuring(true); - device().pressHome(); + goHome(); Thread.sleep(3000); //Leaving the screen ends the measurement on purpose; pinned is that the app comes back, serves and runs again @@ -194,12 +355,13 @@ public void withTheSettingOnTheRunContinuesInTheBackground() throws Exception { awaitApi(); api("/control?cmd=start"); awaitMeasuring(true); + assertTrue("the foreground service did not start with the run", eventually(this::serviceInForeground)); - device().pressHome(); + goHome(); Thread.sleep(3000); - //The remote server rests in the background, so the stopped activity is asked directly - assertTrue("the measurement stopped in the background", activityMeasuring()); - assertTrue("the foreground service is not running", MeasurementService.isRunningFor(experimentActivityInAnyStage())); + //The remote server keeps serving a run that goes on in the background + assertTrue("the measurement stopped in the background", measuring()); + assertTrue("the service is not in the foreground", serviceInForeground()); FixtureExperiment.bringToForeground(); Thread.sleep(2000); @@ -208,7 +370,238 @@ public void withTheSettingOnTheRunContinuesInTheBackground() throws Exception { api("/control?cmd=stop"); awaitMeasuring(false); - assertEquals("the foreground service outlived the run", false, MeasurementService.isRunningFor(experimentActivityInAnyStage())); + assertTrue("the foreground service outlived the run", eventually(() -> !serviceRunning())); + } + + @Test + public void aRemoteStopInTheBackgroundLeavesTheResultForTheUser() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + + goHome(); + Thread.sleep(2000); + api("/control?cmd=stop"); + assertTrue("the remote stop was not carried out in the background", eventually(() -> !runMeasuring())); + + //Nobody has seen the result: the service holds on to the process and the notification says why + assertTrue("the ended run is not waiting for the user", runEndedUnseen()); + assertTrue("the service let go of the unseen result", serviceInForeground()); + assertTrue("the notification does not tell that the run ended: " + notificationText(), eventually(this::notificationSaysEnded)); + assertTrue("the remote server served on after the run ended in the background", eventually(() -> !apiAnswers())); + + FixtureExperiment.bringToForeground(); + awaitApi(); + assertTrue("the service outlived the return to the result", eventually(() -> !serviceRunning())); + assertFalse("the run started again on the return", measuring()); + } + + @Test + public void aTimedRunThatEndsInTheBackgroundWaitsForTheUser() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + Experiment activity = FixtureExperiment.launch(FIXTURE); + awaitApi(); + setTimedRun(activity, 0, 5); + api("/control?cmd=start"); + awaitMeasuring(true); + + goHome(); + assertTrue("the timed run did not end in the background", eventually(this::runEndedUnseen, 15000)); + assertTrue("the service let go of the unseen result", serviceInForeground()); + assertTrue("the notification does not tell that the run ended: " + notificationText(), eventually(this::notificationSaysEnded)); + + FixtureExperiment.bringToForeground(); + awaitApi(); + assertTrue("the service outlived the return to the result", eventually(() -> !serviceRunning())); + } + + @Test + public void theCountdownOfATimedRunGoesOnInTheBackground() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + Experiment activity = FixtureExperiment.launch(FIXTURE); + awaitApi(); + setTimedRun(activity, 3, 60); + api("/control?cmd=start"); //Starts the countdown, and with it the service + goHome(); + + assertTrue("the countdown did not start the measurement in the background", eventually(this::runMeasuring, 15000)); + assertTrue("the service is not in the foreground", serviceInForeground()); + assertTrue("the measurement is not served in the background", measuring()); + api("/control?cmd=stop"); + assertTrue("the remote stop was not carried out in the background", eventually(() -> !runMeasuring())); + } + + //Android kills the app when the service stops before its startForeground, which a zero-length run used to do + @Test + public void aZeroLengthTimedRunEndsWithoutACrash() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + Experiment activity = FixtureExperiment.launch(FIXTURE); + awaitApi(); + setTimedRun(activity, 0, 0); + api("/control?cmd=start"); + + Thread.sleep(3000); + assertTrue("the app stopped answering", apiAnswers()); + assertFalse("a zero-length run is still measuring", measuring()); + assertTrue("the service outlived the zero-length run", eventually(() -> !serviceRunning())); + } + + //Recents trimming removes a task that sat long enough behind others, and a swipe looks the same to the app + @Test + public void aRunGoesOnWhenAndroidRemovesItsScreen() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + assumeTrue("TaskInfo.taskId", Build.VERSION.SDK_INT >= 29); + setBackgroundMeasurement(true); + Experiment first = FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + PhyphoxExperiment experiment = onMain(() -> first.experiment); + + goHome(); + Thread.sleep(1500); + int taskId = first.getTaskId(); + ActivityManager manager = getInstrumentation().getTargetContext().getSystemService(ActivityManager.class); + for (ActivityManager.AppTask task : manager.getAppTasks()) + if (task.getTaskInfo().taskId == taskId) + task.finishAndRemoveTask(); + assertTrue("the screen survived the removal of its task", eventually(() -> experimentActivities().isEmpty())); + Thread.sleep(1000); + assertTrue("the run ended with its screen", runMeasuring()); + assertTrue("the service ended with the screen", serviceInForeground()); + + //The notification brings a new screen for the same run, data included + tapNotification(); + Experiment second = FixtureExperiment.awaitLoaded(); + assertNotSame("no new screen", first, second); + assertSame("the new screen loaded the experiment afresh instead of showing the run", experiment, onMain(() -> second.experiment)); + assertTrue("the new screen does not show the run as measuring", onMain(second::isMeasuring)); + awaitApi(); + assertTrue("the remote interface does not see the run", measuring()); + + api("/control?cmd=stop"); + awaitMeasuring(false); + assertTrue("the foreground service outlived the run", eventually(() -> !serviceRunning())); + } + + @Test + public void theNotificationReturnsToTheScreenOfTheRun() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + Experiment first = FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + + goHome(); + Thread.sleep(1500); + tapNotification(); + assertTrue("the screen of the run did not come back", eventually(() -> FixtureExperiment.activity() != null)); + assertSame("the notification opened another screen instead of returning", first, FixtureExperiment.activity()); + assertTrue("the run did not go on", measuring()); + + api("/control?cmd=stop"); + awaitMeasuring(false); + } + + //The notification's Stop ends the run but keeps the unseen result; its Close lets the process go + @Test + public void theNotificationStopsTheRunAndClosesItsResult() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + goHome(); + assertTrue("no notification for the running measurement", eventually(() -> notificationAction(0) != null)); + + notificationAction(0).actionIntent.send(); //Stop + assertTrue("the notification's stop did not end the run", eventually(() -> !runMeasuring())); + assertTrue("the ended run is not waiting for the user", runEndedUnseen()); + assertTrue("the notification does not tell that the run ended: " + notificationText(), eventually(this::notificationSaysEnded)); + + notificationAction(0).actionIntent.send(); //Close + assertTrue("the service outlived the closed result", eventually(() -> !serviceRunning())); + assertTrue("the closed result is still held", onMain(() -> MeasurementRun.current() == null)); + } + + //Android 14+ lets the user swipe away the notification of a foreground service, which runs on without it + @Test + public void aSwipedAwayNotificationOfARunningMeasurementComesBack() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + assumeTrue("dismissable service notifications", Build.VERSION.SDK_INT >= 34); + setBackgroundMeasurement(true); + FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + goHome(); + assertTrue("no notification for the running measurement", eventually(() -> notificationText() != null)); + + long posted = notificationPostTime(); + String running = getInstrumentation().getTargetContext().getString(R.string.backgroundMeasurementNotification, "").trim(); + device().openNotification(); + androidx.test.uiautomator.UiObject2 notification = device().wait(Until.findObject(By.textStartsWith(running)), 5000); + assumeTrue("the notification is not in the shade", notification != null); + notification.swipe(androidx.test.uiautomator.Direction.RIGHT, 1.0f); + assertTrue("the notification was not swiped away and posted again", eventually(() -> notificationPostTime() > posted)); + device().pressBack(); + assertTrue("the swipe ended the run", runMeasuring()); + } + + //The proximity lock turns off the screen for whatever app is in front; it belongs to the experiment screen only + @Test + public void theProximityLockIsLeftBehindWithTheScreen() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + setBackgroundMeasurement(true); + Context app = getInstrumentation().getTargetContext(); + androidx.preference.PreferenceManager.getDefaultSharedPreferences(app).edit().putBoolean("proximityLock", true).commit(); + try { + FixtureExperiment.launch(FIXTURE); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + assumeTrue("the device has no proximity lock", eventually(this::proximityLockHeld)); + + goHome(); + assertTrue("the proximity lock stayed with the run in the background", eventually(() -> !proximityLockHeld())); + assertTrue("the run did not go on", runMeasuring()); + + FixtureExperiment.bringToForeground(); + assertTrue("the proximity lock did not come back with the screen", eventually(this::proximityLockHeld)); + } finally { + androidx.preference.PreferenceManager.getDefaultSharedPreferences(app).edit().putBoolean("proximityLock", false).commit(); + } + } + + //One run at a time goes on without its screen: a second experiment asks before it ends the first one's + @Test + public void aSecondExperimentAsksBeforeEndingTheRunInTheBackground() throws Exception { + assumeTrue(FixtureExperiment.available(FIXTURE)); + assumeTrue(FixtureExperiment.available("edits.phyphox")); + shell("setprop debug.phyphox.remote '\"\"'"); //Two screens would compete for one port; the test asks the screens directly + setBackgroundMeasurement(true); + Experiment first = FixtureExperiment.launch(FIXTURE); + assertTrue("the first experiment did not start", onMain(first::startMeasurement)); + goHome(); + Thread.sleep(1000); + + Experiment second = FixtureExperiment.launch("edits.phyphox"); + assertFalse("the second experiment started without asking", onMain(second::startMeasurement)); + assertTrue("the first run ended without asking", runMeasuring()); + + androidx.test.uiautomator.UiObject2 start = device().wait(Until.findObject(By.text(java.util.regex.Pattern.compile("(?i)" + getInstrumentation().getTargetContext().getString(R.string.start)))), 5000); + assertTrue("no question about the run in the background", start != null); + start.click(); + assertTrue("the second experiment did not start after the answer", eventually(() -> onMain(second::isMeasuring))); + assertTrue("the first run was not let go", onMain(() -> MeasurementRun.current() != null && MeasurementRun.current().experiment == second.experiment)); } @Test diff --git a/app/src/androidTest/java/de/rwth_aachen/phyphox/ViewBehaviorTest.java b/app/src/androidTest/java/de/rwth_aachen/phyphox/ViewBehaviorTest.java index f37af669..5851bcc6 100644 --- a/app/src/androidTest/java/de/rwth_aachen/phyphox/ViewBehaviorTest.java +++ b/app/src/androidTest/java/de/rwth_aachen/phyphox/ViewBehaviorTest.java @@ -270,7 +270,7 @@ public void aNanIsReplacedByTheDefault() throws Exception { assertBuffer("dropdown_nan", 2); assertBuffer("edit_nan", 12); assertBuffer("slider_nan", 3); - assertTrue("replacing a NaN started the experiment", !activity.measuring); + assertTrue("replacing a NaN started the experiment", !activity.isMeasuring()); } finally { FixtureExperiment.close(activity); } diff --git a/app/src/main/java/de/rwth_aachen/phyphox/Bluetooth/Bluetooth.kt b/app/src/main/java/de/rwth_aachen/phyphox/Bluetooth/Bluetooth.kt index 1adbc011..a1425570 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/Bluetooth/Bluetooth.kt +++ b/app/src/main/java/de/rwth_aachen/phyphox/Bluetooth/Bluetooth.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.io.Serializable +import java.lang.ref.WeakReference import java.nio.ByteBuffer import java.util.UUID import java.util.Vector @@ -154,6 +155,17 @@ open class Bluetooth( private val connectedDeviceInformation = ConnectedDeviceInfo() + //The experiment screen showing this device's battery and signal strength. Weak: a run may outlive its screen. + @Transient + @JvmField + var connectedDeviceDelegate: WeakReference? = null + + //The experiment screen that carries out this device's commands, the one showing its run (not the activity that + // loaded it, which may be gone). Weak for the same reason. + @Transient + @JvmField + var commandDelegate: WeakReference? = null + @Transient private var lastToastShown = 0L @@ -552,7 +564,7 @@ open class Bluetooth( private fun updateConnectedDeviceInfo(rssi: Int) { connectedDeviceInformation.signalStrength = rssi mainHandler.post { - Experiment.updateConnectedDeviceDelegate?.updateConnectedDevice(arrayListOf(connectedDeviceInformation)) + connectedDeviceDelegate?.get()?.updateConnectedDevice(arrayListOf(connectedDeviceInformation)) } } @@ -609,7 +621,7 @@ open class Bluetooth( Log.w(TAG, "Could not subscribe to the command characteristic") } - //A notification on the command characteristic. The activity carries the command out like a tap on + //A notification on the command characteristic. The screen carries the command out like a tap on // the corresponding button; an unknown command is ignored by contract so newer devices stay harmless. internal fun handleCommand(data: ByteArray) { val command = BluetoothCommand.decode(data) @@ -617,7 +629,7 @@ open class Bluetooth( Log.d(TAG, "Ignoring unknown command from $deviceName: " + (data.firstOrNull()?.let { "0x%02x".format(it.toInt() and 0xff) } ?: "empty")) return } - (activity as? BluetoothCommandDelegate)?.onBluetoothCommand(command, this) + commandDelegate?.get()?.onBluetoothCommand(command, this) } /** Attributes of a characteristic as defined in the phyphox file */ diff --git a/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java b/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java index 1ba8ab82..b4d867e9 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java @@ -28,8 +28,8 @@ import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; -import android.os.CountDownTimer; import android.os.Handler; +import android.os.Looper; import android.os.PowerManager; import android.provider.ContactsContract; import android.text.Layout; @@ -92,6 +92,7 @@ import java.io.File; import java.io.FileOutputStream; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -109,8 +110,6 @@ import de.rwth_aachen.phyphox.Bluetooth.Bluetooth; import de.rwth_aachen.phyphox.Bluetooth.BluetoothCommand; import de.rwth_aachen.phyphox.Bluetooth.BluetoothCommandDelegate; -import de.rwth_aachen.phyphox.Bluetooth.BluetoothInput; -import de.rwth_aachen.phyphox.Bluetooth.BluetoothOutput; import de.rwth_aachen.phyphox.Bluetooth.ConnectedBluetoothDeviceInfoAdapter; import de.rwth_aachen.phyphox.Bluetooth.ConnectedDeviceInfo; import de.rwth_aachen.phyphox.Bluetooth.UpdateConnectedDeviceDelegate; @@ -131,7 +130,10 @@ public class Experiment extends AppCompatActivity implements View.OnClickListener, NetworkConnection.ScanDialogDismissedDelegate, NetworkConnection.NetworkConnectionDataPolicyInfoDelegate, UpdateConnectedDeviceDelegate, - BluetoothCommandDelegate { + BluetoothCommandDelegate, MeasurementRun.Client { + + //From the notification of a run that went on in the background (see MeasurementService.returnIntent) + static final String EXTRA_RETURN_TO_RUN = "de.rwth_aachen.phyphox.returnToRun"; //String constants to identify values saved in onSaveInstanceState private static final String STATE_CURRENT_VIEW = "current_view"; //Which experiment view is selected? @@ -160,13 +162,15 @@ public class Experiment extends AppCompatActivity implements View.OnClickListene final Handler updateViewsHandler = new Handler(); //Status variables - boolean measuring = false; //Measurement running? + volatile MeasurementRun measurementRun = null; //The measurement shown here, created at the first start or attached to (see MeasurementRun); read by the remote server's thread + boolean takenOver = false; //Another Experiment activity attached to this one's run, which this one must no longer touch + boolean onScreen = false; //Between onStart and onStop boolean loadCompleted = false; //Set to true when an experiment has been loaded successfully float titleDefaultTextSize = 0f; //Size of the title before it has been fitted to the toolbar int titleFittedForWidth = -1; //Toolbar width the title has last been fitted to - boolean shutdown = false; //The activity should be stopped. Used to escape the measurement loop. - boolean viewsPaused = false; //The activity is stopped while the measurement continues in the background (see MeasurementService); the view loop rests until onRestart + boolean shutdown = false; //The activity should be stopped. Used to escape the view loop. + boolean viewsPaused = false; //The screen is away while the run goes on (see MeasurementRun); the view loop only carries out remote commands until onRestart boolean backgroundMeasurement = false; //Setting: keep a running measurement alive while the screen is not visible boolean beforeStart = true; //Experiment has not yet been started even once boolean menuHintDismissed = false; //Remember that the user has clicked away the hint to the menu @@ -199,8 +203,6 @@ public class Experiment extends AppCompatActivity implements View.OnClickListene boolean timedRunBeepStart = false; boolean timedRunBeepRunning = false; boolean timedRunBeepStop = false; - CountDownTimer cdTimer = null; //This holds the timer used for timed runs. If it is not null, a timed run is running and at the end of the countdown the measurement state will change - long millisUntilFinished = 0; //This variable is used to cache the remaining countdown, so it is available outside the onTick-callback of the timer //The experiment PhyphoxExperiment experiment; //The experiment (definition and functionality) after it has been loaded. @@ -221,13 +223,11 @@ public class Experiment extends AppCompatActivity implements View.OnClickListene PowerManager.WakeLock wakeLock = null; PopupWindow popupWindow = null; - AudioOutput audioOutput = null; public static boolean bluetoothConnectionSuccessful = false; ConnectedBluetoothDeviceInfoAdapter deviceInfoAdapter; ArrayList connectedDevices = new ArrayList<>(); - public static UpdateConnectedDeviceDelegate updateConnectedDeviceDelegate; private RecyclerView recyclerView; static final int REQUEST_LOCAL_NETWORK_SCAN = 2; @@ -317,8 +317,6 @@ public void handleOnBackPressed() { overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, R.anim.hold, R.anim.exit_experiment); } - updateConnectedDeviceDelegate = this; - intent = getIntent(); //Store the intent for easy access res = getResources(); //The same for resources sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE); //The sensor manager will probably be needed... @@ -342,7 +340,16 @@ public void handleOnBackPressed() { App app = (App) this.getApplicationContext(); experiment = app.experiment; //experiment = (phyphoxExperiment) savedInstanceState.getSerializable(STATE_EXPERIMENT); - }; + } else if (savedInstanceState == null && intent.getBooleanExtra(EXTRA_RETURN_TO_RUN, false)) { + //The notification of a run whose screen is gone: show the run as that screen last was + MeasurementRun running = MeasurementRun.current(); + if (running == null) { //It has ended and been seen since + navigateUpToExperimentList(); + return; + } + experiment = running.experiment; + this.savedInstanceState = running.screenState; + } if (experiment != null) { //We saved our experiment. Lets just retrieve it and continue onExperimentLoaded(experiment); @@ -367,23 +374,35 @@ private void shutdownIO() { if (shutdown) return; shutdown = true; //Stop the loop + if (takenOver) + return; //The run and its connections belong to the activity that took it over stopMeasurement(); //Stop the measurement - if (experiment != null && experiment.loaded) { - for (NetworkConnection networkConnection : experiment.networkConnections) { - networkConnection.disconnect(); - networkConnection.specificAddress = null; - } - //Close all bluetooth connections, when the activity is recreated, they will be reestablished while initializing the experiment - for (BluetoothInput bti : experiment.bluetoothInputs) - bti.closeConnection(); - for (BluetoothOutput bti : experiment.bluetoothOutputs) - bti.closeConnection(); - if (experiment.depthInput != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - experiment.depthInput.stopCameras(); + //Close all connections, when the activity is recreated, they will be reestablished while initializing the experiment + if (experiment != null) + experiment.closeConnections(); + } - } + //Whether this screen's run goes on once the screen is gone (see MeasurementRun): with the service when the user + //leaves or Android removes the activity, and for a recreation (configuration change), after which the new + //activity attaches. Camera runs stop as they always did: the cameras close with the screen. + private boolean runGoesOnWithoutScreen(boolean destroying) { + MeasurementRun run = measurementRun; + if (run == null || takenOver || !run.isClient(this) || !run.isActive()) + return false; + if (isChangingConfigurations()) + return !MeasurementRun.usesCamera(experiment); + //An activity finished by code while visible means leaving; one destroyed in the background may be Recents trimming + return run.inBackground() && (destroying || !isFinishing()); + } + + @Override + protected void onStart() { + super.onStart(); + onScreen = true; + if (measurementRun != null) + measurementRun.setClientVisible(this, true); } @Override @@ -400,16 +419,20 @@ public void onStop() { progress = null; } - stopRemoteServer(); //Remote server should stop when the app is not active - if (measuring && backgroundMeasurement && MeasurementService.isRunningFor(this) && !isFinishing()) { - //The run goes on under the foreground service (see MeasurementService): inputs, connections and the - //analysis thread stay as they are, only the view loop rests. The depth camera closes as the system - //would revoke it in the background anyway; onRestart reopens it. + if (runGoesOnWithoutScreen(false)) { + //Inputs, connections and the analysis thread stay as they are, and the remote server keeps serving the + //run. The view loop only carries out remote commands, and the proximity lock would blank other apps. viewsPaused = true; - if (experiment != null && experiment.depthInput != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - experiment.depthInput.stopCameras(); - } else + releaseProximityLock(); + if (isChangingConfigurations()) + stopRemoteServer(); //The recreated activity starts its own + } else { + stopRemoteServer(); //Remote server should stop when the app is not active shutdownIO(); + } + onScreen = false; + if (measurementRun != null) + measurementRun.setClientVisible(this, false); if (popupWindow != null) popupWindow.dismiss(); @@ -424,7 +447,7 @@ public void onRestart() { super.onRestart(); shutdown = false; //Deactivate shutdown variable - viewsPaused = false; //Back on screen: the view loop picks up a run that continued in the background + viewsPaused = false; //Back on screen: the view loop shows a run that went on in the background again if (experiment != null && experiment.depthInput != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { try { @@ -435,21 +458,45 @@ public void onRestart() { } updateViewsHandler.postDelayed(updateViews, 40); //Start the "main loop" again - startRemoteServer(); //Restart the remote server (if it is activated) + if (remote == null) + startRemoteServer(); //Restart the remote server (if it is activated and did not serve a run in the background) //We do not start the measurement again automatically. If the user switched away, this might // be confusing otherwise. + if (measurementRun != null && measurementRun.isActive()) + setKeepScreenOn(true); //The proximity lock was released in the background invalidateOptionsMenu(); } @Override - //A run that continued in the background ends with the activity that owns it. Recreation (locale, dark - //mode, split screen) rebuilds the UI from app.experiment without the measuring state, so it stops the run as before. + //A run that goes on without its screen detaches and waits for the next Experiment activity (see MeasurementRun) protected void onDestroy() { - shutdownIO(); + MeasurementRun run = measurementRun; + if (runGoesOnWithoutScreen(true)) + run.detach(this); + else { + shutdownIO(); + if (run != null) + run.detach(this); + } + stopRemoteServer(); + updateViewsHandler.removeCallbacksAndMessages(null); + releaseProximityLock(); super.onDestroy(); } + @Override + //The notification of a run in the background returns here (see MeasurementService.returnIntent). If this + //screen shows another experiment, the run's screen opens on top of it. + protected void onNewIntent(Intent newIntent) { + super.onNewIntent(newIntent); + if (!newIntent.getBooleanExtra(EXTRA_RETURN_TO_RUN, false)) + return; + MeasurementRun running = MeasurementRun.current(); + if (running != null && running != measurementRun) + startActivity(new Intent(newIntent).setFlags(0)); + } + @Override //Config changes declared in the manifest do not recreate the activity and do not affect our UI public void onConfigurationChanged(Configuration newConfig) { @@ -710,11 +757,30 @@ public void onExperimentLoaded(PhyphoxExperiment experiment) { return; } + //A run of this very experiment goes on (a recreation, or the notification of a run without screen): show it + MeasurementRun running = MeasurementRun.current(); + boolean attached = experiment.loaded && running != null && running.experiment == experiment; + if (attached) { + measurementRun = running; + running.attach(this, onScreen); + } + if (experiment.loaded) { //Everything went fine, no errors if (experiment.gpsIn != null) { experiment.gpsIn.prepare(res); } + //Battery and signal strength of the experiment's devices go to this screen (see updateConnectedDevice), + //and so do their commands (see onBluetoothCommand) + for (Bluetooth b : experiment.bluetoothInputs) { + b.connectedDeviceDelegate = new WeakReference<>(this); + b.commandDelegate = new WeakReference<>(this); + } + for (Bluetooth b : experiment.bluetoothOutputs) { + b.connectedDeviceDelegate = new WeakReference<>(this); + b.commandDelegate = new WeakReference<>(this); + } + timedRun = experiment.timedRun; timedRunStartDelay = experiment.timedRunStartDelay; timedRunStopDelay = experiment.timedRunStopDelay; @@ -875,7 +941,9 @@ public void onFailure(Throwable error) { if (startHintDismissCount >= 3) startHintDismissed = true; - if (!menuHintDismissed) + if (attached) + beforeStart = false; //The run began on another screen, which went through the hints and dialogs already + else if (!menuHintDismissed) showMenuHint(); else if (!startHintDismissed) showStartHint(); @@ -889,8 +957,25 @@ else if (!startHintDismissed) wakeLock = powerManager.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "phyphox:measuring"); } - //All done. Almost. Now let's resolve anything the user needs to know or needs to decide - showInitialDialogs(); + if (attached) { + if (running.isActive()) + setKeepScreenOn(true); + if (anyBluetoothConnected()) + showBluetoothConnectedDeviceInfo(); + invalidateOptionsMenu(); + } else + //All done. Almost. Now let's resolve anything the user needs to know or needs to decide + showInitialDialogs(); + } + + private boolean anyBluetoothConnected() { + for (Bluetooth b : experiment.bluetoothInputs) + if (b.isConnected()) + return true; + for (Bluetooth b : experiment.bluetoothOutputs) + if (b.isConnected()) + return true; + return false; } private void showSensorWarning(SensorInput sensor) { @@ -1230,10 +1315,12 @@ public boolean onPrepareOptionsMenu(Menu menu) { //If a timed run timer is active, we show either timed_play or timed_pause. otherwise we show play or pause //The pause version is shown when we are measuring, the play version otherwise - timed_play.setVisible(!measuring && cdTimer != null); - timed_pause.setVisible(measuring && cdTimer != null); - play.setVisible(!measuring && cdTimer == null); - pause.setVisible(measuring && cdTimer == null); + boolean measuring = isMeasuring(); + boolean timerRunning = measurementRun != null && measurementRun.timerRunning(); + timed_play.setVisible(!measuring && timerRunning); + timed_pause.setVisible(measuring && timerRunning); + play.setVisible(!measuring && !timerRunning); + pause.setVisible(measuring && !timerRunning); //If the experiment has not yet been started, highlight the play button if (beforeStart) { @@ -1288,8 +1375,8 @@ public boolean onPrepareOptionsMenu(Menu menu) { //If the timedRun is active, we have to set the value of the countdown if (timedRun) { - if (cdTimer != null) { //Timer running? Show the last known value of millisUntilFinished - timer.setTitle(String.format(Locale.getDefault(), "%.1f", millisUntilFinished / 1000.0)); + if (timerRunning) { //Timer running? Show the last known value of millisUntilFinished + timer.setTitle(String.format(Locale.getDefault(), "%.1f", countdownMillis() / 1000.0)); } else { //No timer running? Show the start value of the next timer, which is... if (measuring) //...the stop delay if we are already measuring timer.setTitle(String.format(Locale.getDefault(), "%.1f", timedRunStopDelay)); @@ -1585,33 +1672,7 @@ public void onClick(DialogInterface dialog, int id) { // overridden version of the run() function. But it feels better to write the whole thing down // like a function... - //The updateData runnable runs on a second thread and performs the heavy math (if defined in the - // experiment) - Runnable updateData = new Runnable() { - @Override - public void run() { - //Do the analysis. All of these elements might fire exceptions, - // especially on badly defined experiments. So let's be save and catch anything that - // gets through to here. - while (measuring && !shutdown) { - if (experiment != null) { //This only makes sense if there is an experiment - try { - //time for some analysis? - if (measuring) { - experiment.processAnalysis(true); //Do the math. - } - } catch (Exception e) { - Log.e("updateData", "Unhandled exception.", e); - } - } - try { - Thread.sleep(10); - } catch (Exception e) { - Log.w("updateData", "Sleep interrupted"); - } - } - } - }; + //The heavy math of a running measurement runs on a thread of its MeasurementRun //The updateViews runnable does everything UI related and hence runs on the UI thread Runnable updateViews = new Runnable() { @@ -1644,13 +1705,20 @@ public void run() { } } + if (viewsPaused) { + //The screen is away while the run goes on: only the remote commands above are carried out + if (!shutdown) + updateViewsHandler.postDelayed(this, 400); + return; + } + updateBackCallbackState(); if (experiment != null) { try { //Get values from input views only if there isn't fresh data from the remote server which might get overridden if (!remoteInput) { - experiment.handleInputViews(measuring); + experiment.handleInputViews(isMeasuring()); } //Update all the views currently visible if (experiment.updateViews(tabLayout.getSelectedTabPosition(), false)) { @@ -1666,7 +1734,7 @@ public void run() { } finally { //If we are not supposed to stop, let's do it again in a short while if (!shutdown && !viewsPaused) { - if (measuring) + if (isMeasuring()) updateViewsHandler.postDelayed(this, 40); else updateViewsHandler.postDelayed(this, 400); //As there is no experiment running, we can take our time and maybe save some battery @@ -1733,98 +1801,85 @@ public void onClick(DialogInterface dialog, int id) { //Returns false if the experiment refused to start, which the remote interface reports back public boolean startMeasurement() { + if (shutdown || experiment == null) + return false; //The screen has shut down: a remote start queued before it left must not start a run + if (anotherRunIsHeld(this::startMeasurement)) + return false; //Disable play-button highlight beforeStart = false; - //Start the sensors + //Start the sensors. With the setting on, the service starts too: now, while the activity is visible, or + //Android refuses it the location and microphone types (see MeasurementRun and MeasurementService). try { - experiment.startAllIO(); - } catch (Bluetooth.BluetoothException e) { - stopMeasurement(); // stop experiment + ownRun().start(backgroundAllowed(), timedRun ? timing() : null); + } catch (Bluetooth.BluetoothException | DepthInput.DepthInputException e) { + showStartFailure(e); + return false; + } + return true; + } + + //Why the inputs did not start, for a start from here or at the end of a countdown + private void showStartFailure(Exception e) { + if (e instanceof Bluetooth.BluetoothException) { // show an error dialog Bluetooth.errorDialog.message = e.getMessage(); Bluetooth.errorDialog.context = Experiment.this; // try to connect the bluetooth devices again when the user clicks "try again" - Bluetooth.errorDialog.tryAgain = new Runnable() { - @Override - public void run() { - connectBluetoothDevices(true, false); - } - }; - Bluetooth.errorDialog.run(); - return false; - } catch (DepthInput.DepthInputException e) { - stopMeasurement(); // stop experiment + Bluetooth.errorDialog.tryAgain = () -> connectBluetoothDevices(true, false); + Bluetooth.errorDialog.run(); + } else { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(e.getMessage()) .setPositiveButton(R.string.ok, null); AlertDialog dialog = builder.create(); dialog.show(); } + } - //Set measurement state - measuring = true; - - //No more turning off during the measurement - setKeepScreenOn(true); - - //With the setting on, a foreground service keeps the run alive when the screen goes away. It has to - //start now, while the activity is visible, or Android refuses it the location and microphone types. - if (backgroundMeasurement) - MeasurementService.start(this, experiment); - - //Start the analysis "loop" - Thread t = new Thread(updateData); - t.start(); + //The run of this screen's experiment, created at its first start + private MeasurementRun ownRun() { + if (measurementRun == null) { + measurementRun = new MeasurementRun(this, experiment, getIntent()); + measurementRun.attach(this, onScreen); + } + return measurementRun; + } - //If this is a timed run, we have to start the countdown which stops it again later. - if (timedRun) { + //Camera experiments pause in the background as they always did: the cameras close with the screen + private boolean backgroundAllowed() { + return backgroundMeasurement && !MeasurementRun.usesCamera(experiment); + } - millisUntilFinished = Math.round(timedRunStopDelay * 1000); - cdTimer = new CountDownTimer(millisUntilFinished, 20) { - - int nextBeep = -1; - boolean relative = false; - - public void onTick(long muf) { - //On each tick update the menu to show the remaining time - if (timedRunBeepRunning || timedRunBeepStop) { - if (nextBeep < 0) - nextBeep = (int)(Math.floor(muf/1000. - 0.6)); - if (muf/1000. < nextBeep + 0.4) { - if (nextBeep == 0 && timedRunBeepStop) { - if (relative) - audioOutput.beepRelative(800, 0.5, 1.0); - else { - audioOutput.beep(800, 0.5, muf / 1000. - nextBeep); - relative = true; - } - } else if (nextBeep > 0 && timedRunBeepRunning) { - if (relative) - audioOutput.beepRelative(1000, 0.1, 1.0); - else { - audioOutput.beep(1000, 0.1, muf / 1000. - nextBeep); - relative = true; - } - } - nextBeep--; - } - } - millisUntilFinished = muf; - invalidateOptionsMenu(); - } + private MeasurementRun.Timing timing() { + return new MeasurementRun.Timing(timedRunStartDelay, timedRunStopDelay, timedRunBeepCountdown, timedRunBeepStart, timedRunBeepRunning, timedRunBeepStop); + } - public void onFinish() { - stopMeasurement(); - } - }.start(); - } - invalidateOptionsMenu(); + //One run at a time goes on without its screen (the service and its notification serve one). If another one + //does, the user decides whether it ends for this one. + private boolean anotherRunIsHeld(Runnable startThisOne) { + MeasurementRun other = MeasurementRun.current(); + if (other == null || other == measurementRun) + return false; + int message = other.isActive() ? R.string.backgroundMeasurementOtherRunning : R.string.backgroundMeasurementOtherEnded; + new AlertDialog.Builder(this) + .setMessage(getString(message, other.experiment.title)) + .setPositiveButton(R.string.start, (dialog, id) -> { + other.release(); + startThisOne.run(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); return true; } //Returns true once the countdown runs (openapi: "begin measuring, or begin the countdown if timed run is on") public boolean startTimedMeasurement() { + if (shutdown || experiment == null) + return false; //See startMeasurement + if (anotherRunIsHeld(this::startTimedMeasurement)) + return false; + //No more turning off during the measurement setKeepScreenOn(true); @@ -1859,79 +1914,22 @@ public void run() { return false; } - if (timedRunBeepCountdown || timedRunBeepStart || timedRunBeepRunning || timedRunBeepStop) { - if (experiment.audioOutput == null) { - if (audioOutput == null) { - audioOutput = new AudioOutput(false, 48000, true); - try { - audioOutput.init(); - } catch (Exception e) { - return false; - } - } - } else - audioOutput = experiment.audioOutput; - audioOutput.start(true); - audioOutput.play(); - } - - //Not much more to do here. Just set up a countdown that will start the measurement - millisUntilFinished = Math.round(timedRunStartDelay*1000); - cdTimer = new CountDownTimer(millisUntilFinished, 20) { - int nextBeep = -1; - boolean relative = false; - - public void onTick(long muf) { - //On each tick update the menu to show the remaining time - if (timedRunBeepCountdown || timedRunBeepStart) { - if (nextBeep < 0) - nextBeep = (int)(Math.floor(muf/1000. - 0.5)); - if (muf/1000. < nextBeep + 0.4) { - if (nextBeep == 0 && timedRunBeepStart) { - if (relative) - audioOutput.beepRelative(1000, 0.5, 1.0); - else { - audioOutput.beep(1000, 0.5, muf / 1000. - nextBeep); - relative = true; - } - } else if (nextBeep > 0 && timedRunBeepCountdown) { - if (relative) - audioOutput.beepRelative(800, 0.1, 1.0); - else { - audioOutput.beep(800, 0.1, muf / 1000. - nextBeep); - relative = true; - } - } - nextBeep--; - } - } - millisUntilFinished = muf; - invalidateOptionsMenu(); - } - - public void onFinish() { - startMeasurement(); - } - }.start(); + //Not much more to do here. Just set up a countdown that will start the measurement. The service starts with + //it, while the screen is visible, so the run goes on if the screen goes away during the countdown. + if (!ownRun().startCountdown(backgroundAllowed(), timing())) + return false; //The beeper cannot play invalidateOptionsMenu(); return true; } - //Stop the measurement + //Stop the measurement. Any thread: the remote server's clear calls it from its own. public void stopMeasurement() { - measuring = false; //Set the state - MeasurementService.stop(this); //Ends the background run's service and notification; a no-op without them - - //Lift the restrictions, so the screen may turn off again and the user may rotate the device (unless remote server is active) - if (!serverEnabled) { - setKeepScreenOn(false); - } - - //Deactivate any timer - if (cdTimer != null) { - cdTimer.cancel(); - cdTimer = null; - millisUntilFinished = 0; + if (takenOver) + return; //The run belongs to another screen now + MeasurementRun run = measurementRun; + if (run != null && run.isActive()) { + run.stop(); //Reports back through onRunStopped + return; } //Stop any inputs (Sensors, microphone) or outputs (speaker) that might still be running @@ -1940,10 +1938,63 @@ public void stopMeasurement() { experiment.stopAllIO(); } - //refresh the options menu + //Lift the restrictions, so the screen may turn off again and the user may rotate the device (unless remote server is active) + if (!serverEnabled && Looper.myLooper() == Looper.getMainLooper()) + setKeepScreenOn(false); + } + + //Whether the run shown here measures (the remote server asks from its own thread) + boolean isMeasuring() { + MeasurementRun run = measurementRun; + return run != null && run.measuring; + } + + //The remaining time of a timed run's running countdown in ms (the remote server asks from its own thread) + long countdownMillis() { + MeasurementRun run = measurementRun; + return run != null ? run.millisUntilFinished : 0; + } + + @Override + public void onRunStarted() { + beforeStart = false; //Disable play-button highlight + if (onScreen) + setKeepScreenOn(true); //No more turning off during the measurement (in the background it would take the proximity lock) invalidateOptionsMenu(); } + @Override + public void onRunStopped(boolean seen) { + //Lift the restrictions, so the screen may turn off again and the user may rotate the device (unless remote server is active) + if (!serverEnabled) + setKeepScreenOn(false); + invalidateOptionsMenu(); + if (!seen) { + //It ended while the screen was away (its timer, the notification, a remote stop): leave everything as + //leaving the screen does, the connections included + stopRemoteServer(); + shutdownIO(); + } + } + + @Override + public void onRunCountdownChanged() { + invalidateOptionsMenu(); //Shows the remaining time + } + + @Override + public void onRunStartFailed(Exception e) { + showStartFailure(e); + } + + @Override + public void onRunTakenOver() { + takenOver = true; + measurementRun = null; + stopRemoteServer(); //Now, not in onDestroy: the new screen starts its own on the same port + finish(); + } + public void clearData(List clearGroups) { //Clear the buffers @@ -2076,7 +2127,7 @@ private void openDialogWithQrCode(String serverAddress){ //Stop the remote server (see remoteServer class) private void stopRemoteServer() { - if (!measuring) { + if (!isMeasuring()) { setKeepScreenOn(false); } //Announce this to the user, so he knows why the webinterface stopped working. @@ -2122,12 +2173,12 @@ public boolean remoteStartMeasurement() { try { if (!verdict.await(REMOTE_START_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { remoteStartVerdict = null; - return measuring; //never processed - report what is actually the case + return isMeasuring(); //never processed - report what is actually the case } } catch (InterruptedException e) { Thread.currentThread().interrupt(); remoteStartVerdict = null; - return measuring; + return isMeasuring(); } return remoteStartSucceeded; } @@ -2139,9 +2190,10 @@ public boolean remoteStartMeasurement() { @Override public void onBluetoothCommand(BluetoothCommand command, Bluetooth device) { runOnUiThread(() -> { - if (experiment == null || !experiment.loaded) + if (takenOver || experiment == null || !experiment.loaded) return; - boolean active = measuring || cdTimer != null; + MeasurementRun run = measurementRun; + boolean active = run != null && run.isActive(); switch (command) { case START: if (!active && isBluetoothConnectionSuccessful) @@ -2164,7 +2216,7 @@ else if (isBluetoothConnectionSuccessful) clearData(Arrays.asList(experiment.getClearGroups())); break; case STATUS: - device.writeStatusEvent(measuring, experiment.experimentTimeReference.getExperimentTime()); + device.writeStatusEvent(isMeasuring(), experiment.experimentTimeReference.getExperimentTime()); break; } }); @@ -2226,6 +2278,11 @@ protected void onSaveInstanceState(Bundle outState) { outState.putBoolean(STATE_SENSOR_WARNING_DISMISSED, sensorWarningDismissed); outState.putBoolean(STATE_DATA_POLICY_DISMISSED, dataPolicyDismissed); outState.putBoolean(STATE_PHOTOSENSITIVITY_DISMISSED, photosensitivityWarningDismissed); + + //A run that goes on without this screen continues from this state in the next one (see MeasurementRun) + MeasurementRun run = measurementRun; + if (run != null && run.isClient(this)) + run.screenState = new Bundle(outState); } catch (Exception e) { //Something went wrong? //Discard all the data to get a clean new activity and start fresh. @@ -2244,13 +2301,16 @@ private void setKeepScreenOn(boolean keepOn) { } } else { getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - if (proximityLock && wakeLock.isHeld()) { - if (wakeLock != null) - wakeLock.release(); - } + releaseProximityLock(); } } + //The proximity lock turns off the screen whichever app shows, so it is only held for this screen + private void releaseProximityLock() { + if (wakeLock != null && wakeLock.isHeld()) + wakeLock.release(); + } + Runnable runDeviceUpdate = new Runnable() { @Override public void run() { @@ -2391,7 +2451,7 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { timedRunBeepRunning = cbTimedRunBeeperRunning.isChecked(); timedRunBeepStop = cbTimedRunBeeperStop.isChecked(); - if (timedRun && measuring) + if (timedRun && isMeasuring()) stopMeasurement(); else invalidateOptionsMenu(); diff --git a/app/src/main/java/de/rwth_aachen/phyphox/MeasurementRun.java b/app/src/main/java/de/rwth_aachen/phyphox/MeasurementRun.java new file mode 100644 index 00000000..8aca89f0 --- /dev/null +++ b/app/src/main/java/de/rwth_aachen/phyphox/MeasurementRun.java @@ -0,0 +1,374 @@ +package de.rwth_aachen.phyphox; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.CountDownTimer; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import de.rwth_aachen.phyphox.Bluetooth.Bluetooth; +import de.rwth_aachen.phyphox.camera.depth.DepthInput; + +//One experiment's measurement apart from the screen that shows it: the measuring state, the analysis thread, +//the countdowns of a timed run with their beeps, and the foreground service that keeps it alive in the +//background (see MeasurementService). A run with the service outlives its Experiment activity: when Android +//removes the activity (Recents trimming, a swiped-away task), the run goes on and the next Experiment activity +//for it attaches (from the notification). A configuration change hands any run to the recreated activity. At +//most one run is held like this at a time (current). Everything except stop() runs on the main thread. +public class MeasurementRun { + + //What the Experiment activity showing the run hears, on the main thread + interface Client { + void onRunStarted(); //Measuring began, directly or at the end of a countdown + void onRunStopped(boolean seen); //Measuring or a countdown ended; seen: while the client was on screen + void onRunCountdownChanged(); //The shown remaining time of a countdown changed (only while on screen) + void onRunStartFailed(Exception e); //A countdown ended but an input refused to start + void onRunTakenOver(); //Another Experiment activity shows the run from now on + } + + //The settings of a timed run, fixed when it starts + static final class Timing { + final long startMillis, stopMillis; + final boolean beepCountdown, beepStart, beepRunning, beepStop; + + Timing(double startDelay, double stopDelay, boolean beepCountdown, boolean beepStart, boolean beepRunning, boolean beepStop) { + this.startMillis = Math.round(startDelay * 1000); + this.stopMillis = Math.round(stopDelay * 1000); + this.beepCountdown = beepCountdown; + this.beepStart = beepStart; + this.beepRunning = beepRunning; + this.beepStop = beepStop; + } + + boolean beeps() { + return beepCountdown || beepStart || beepRunning || beepStop; + } + } + + //The run that may be without a screen: counting down or measuring, or ended unseen and kept by the service + private static MeasurementRun current = null; + + final PhyphoxExperiment experiment; + final Intent source; //The intent that opened the experiment, for the notification to reopen it + Bundle screenState = null; //The last saved state of the screen showing the run, for the next one to continue from + + private final Context context; + private final Handler main = new Handler(Looper.getMainLooper()); + private Client client = null; + private boolean clientVisible = false; + + private volatile boolean active = false; //Counting down or measuring + volatile boolean measuring = false; + volatile long millisUntilFinished = 0; //Remaining time of the running countdown + private volatile Object analysisPass = null; //Identifies the analysis thread of the latest start + private CountDownTimer timer = null; + private Timing timing = null; + private AudioOutput beeper = null; + + private boolean background = false; //The service keeps the run alive (requested and not refused) + private boolean finishedUnseen = false; //Ended while nobody watched; the service keeps the data until somebody does + + MeasurementRun(Context context, PhyphoxExperiment experiment, Intent source) { + this.context = context.getApplicationContext(); + this.experiment = experiment; + this.source = source; + } + + static MeasurementRun current() { + return current; + } + + //Camera and depth experiments cannot go on without their screen: the cameras close with it + static boolean usesCamera(PhyphoxExperiment experiment) { + return experiment.cameraInput != null || experiment.depthInput != null; + } + + boolean isActive() { + return active; + } + + boolean isClient(Client c) { + return client == c; + } + + boolean inBackground() { + return background; + } + + boolean isFinishedUnseen() { + return finishedUnseen; + } + + //A countdown of a timed run is running (main thread) + boolean timerRunning() { + return timer != null; + } + + //Starts measuring now. An input that refuses to start ends the run and is thrown for the caller to explain. + void start(boolean background, Timing timing) throws Bluetooth.BluetoothException, DepthInput.DepthInputException { + if (active) + return; + if (timing != null && timing.beeps()) + prepareBeeper(); //Without it the run is silent + this.timing = timing; + begin(background); + beginMeasuring(); + } + + //Starts a timed run's countdown, at the end of which measuring begins. False if the beeper cannot play. + boolean startCountdown(boolean background, Timing timing) { + if (active) + return true; + if (timing.beeps() && !prepareBeeper()) + return false; + this.timing = timing; + begin(background); + startTimer(timing.startMillis, true); + return true; + } + + private void begin(boolean background) { + if (current != null && current != this) + current.release(); //The screen asked the user first + if (timer != null) { //Left by a stop from another thread that has not reached the main thread yet + timer.cancel(); + timer = null; + } + current = this; + active = true; + finishedUnseen = false; + if (background && !this.background) + this.background = MeasurementService.start(context, this); + MeasurementService.update(this); + } + + private void beginMeasuring() throws Bluetooth.BluetoothException, DepthInput.DepthInputException { + try { + experiment.startAllIO(); + } catch (Bluetooth.BluetoothException | DepthInput.DepthInputException e) { + stop(); + throw e; + } + measuring = true; + final Object pass = new Object(); + analysisPass = pass; + new Thread(() -> analyse(pass)).start(); + if (client != null) + client.onRunStarted(); + MeasurementService.update(this); + if (timing != null) + startTimer(timing.stopMillis, false); //Last: a stop delay of zero ends the run right here + } + + //The analysis "loop" of one start, on its own thread + private void analyse(Object pass) { + while (measuring && analysisPass == pass) { + //Badly defined experiments may throw anything here + try { + if (measuring) + experiment.processAnalysis(true); //Do the math. + } catch (Exception e) { + Log.e("updateData", "Unhandled exception.", e); + } + try { + Thread.sleep(10); + } catch (Exception e) { + Log.w("updateData", "Sleep interrupted"); + } + } + } + + //The countdown before a timed run (countdown) or until its end, with the beeps chosen for it + private void startTimer(final long millis, final boolean countdown) { + if (timer != null) + timer.cancel(); + millisUntilFinished = millis; + CountDownTimer t = new CountDownTimer(millis, 20) { + int nextBeep = -1; + boolean relative = false; + long shownTenth = millis / 100; + + @Override + public void onTick(long muf) { + beep(muf); + millisUntilFinished = muf; + //The menu shows tenths of a second: refreshing it more often only costs + if (muf / 100 != shownTenth) { + shownTenth = muf / 100; + if (client != null && clientVisible) + client.onRunCountdownChanged(); + } + } + + //Every second and at the end: of the countdown (start) or of the measurement (stop) + private void beep(long muf) { + boolean everySecond = countdown ? timing.beepCountdown : timing.beepRunning; + boolean atTheEnd = countdown ? timing.beepStart : timing.beepStop; + if (beeper == null || !(everySecond || atTheEnd)) + return; + if (nextBeep < 0) + nextBeep = (int) (Math.floor(muf / 1000. - (countdown ? 0.5 : 0.6))); + if (muf / 1000. < nextBeep + 0.4) { + if (nextBeep == 0 && atTheEnd) + play(countdown ? 1000 : 800, 0.5, muf); + else if (nextBeep > 0 && everySecond) + play(countdown ? 800 : 1000, 0.1, muf); + nextBeep--; + } + } + + private void play(double frequency, double duration, long muf) { + if (relative) + beeper.beepRelative(frequency, duration, 1.0); + else { + beeper.beep(frequency, duration, muf / 1000. - nextBeep); + relative = true; + } + } + + @Override + public void onFinish() { + if (timer == this) + timer = null; + millisUntilFinished = 0; + if (!countdown) { + stop(); + return; + } + try { + beginMeasuring(); + } catch (Bluetooth.BluetoothException | DepthInput.DepthInputException e) { + if (client != null) + client.onRunStartFailed(e); + } + } + }; + timer = t; + t.start(); + } + + //The timed run's beeps sound through the experiment's own audio output if it has one + private boolean prepareBeeper() { + if (experiment.audioOutput != null) + beeper = experiment.audioOutput; + else if (beeper == null) { + AudioOutput output = new AudioOutput(false, 48000, true); + try { + output.init(); + } catch (Exception e) { + return false; + } + beeper = output; + } + beeper.start(true); + beeper.play(); + return true; + } + + //Ends measuring or a countdown. Any thread, as the remote server clears data from its own: the inputs stop + //at once, the rest follows on the main thread. Also called when nothing runs (leaving the screen does). + void stop() { + boolean wasActive = active; + active = false; + measuring = false; + experiment.stopAllIO(); + if (!wasActive) + return; + if (Looper.myLooper() == Looper.getMainLooper()) + afterStop(); + else + main.post(this::afterStop); + } + + private void afterStop() { + if (active) + return; //Started again in the meantime + if (timer != null) { + timer.cancel(); + timer = null; + } + millisUntilFinished = 0; + boolean seen = client != null && clientVisible; + if (background) { + if (seen) + endService(); + else { + finishedUnseen = true; + MeasurementService.update(this); + } + } + if (current == this && !finishedUnseen) + current = null; + if (client != null) + client.onRunStopped(seen); + else + experiment.closeConnections(); //What leaving the screen would have closed + } + + private void endService() { + background = false; + finishedUnseen = false; + MeasurementService.end(context, this); + } + + //Lets go of the run: stops it and ends its service. Another run takes over, or the notification of an ended + //run was closed; the data stays with a screen that still shows it. + void release() { + stop(); + if (background) + endService(); + finishedUnseen = false; + if (current == this) + current = null; + } + + //An Experiment activity shows the run from now on; the one before, if still there, lets go of it + void attach(Client newClient, boolean visible) { + Client old = client; + client = newClient; + clientVisible = false; + if (old != null && old != newClient) + old.onRunTakenOver(); + setClientVisible(newClient, visible); + } + + void setClientVisible(Client c, boolean visible) { + if (client != c) + return; + clientVisible = visible; + if (visible && finishedUnseen) { + //Somebody looks at the ended run now, so the service may let the process go + endService(); + if (current == this) + current = null; + } + } + + //The activity is gone. A run with the service waits for the next one. A run without it only lives through a + //recreation, where the new activity attaches before the posted check. + void detach(Client c) { + if (client != c) + return; + client = null; + clientVisible = false; + if (active && !background) + main.post(() -> { + if (client == null && active && !background) + stop(); + }); + } + + //Android refused or ended the service: the run can only go on while it is seen + void onServiceFailed() { + background = false; + if (finishedUnseen) { + finishedUnseen = false; + if (current == this) + current = null; + } + if (active && (client == null || !clientVisible)) + stop(); + } +} diff --git a/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java b/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java index 0b9e9ff7..87a6a416 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java @@ -1,5 +1,6 @@ package de.rwth_aachen.phyphox; +import android.Manifest; import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationChannel; @@ -8,6 +9,7 @@ import android.app.Service; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.content.pm.ServiceInfo; import android.os.Build; import android.os.IBinder; @@ -16,28 +18,33 @@ import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; import androidx.core.app.ServiceCompat; import androidx.core.content.ContextCompat; -import java.lang.ref.WeakReference; - -//Keeps a running measurement alive while the experiment screen is not visible. Android delivers sensor, -//location and microphone data to an app in the background only through a foreground service: a Service -//pinned by a persistent notification that declares the kinds of data it uses. The service itself does no -//measuring. The Experiment activity survives in the background (stopped, not destroyed), keeps its inputs -//and analysis thread running and only rests its view loop. The service adds the notification and a partial -//wake lock, because a foreground service alone does not keep the CPU awake once the screen turns off. +//Keeps a measurement alive while no experiment screen is visible. Android delivers sensor, location and +//microphone data to an app in the background only through a foreground service: a Service pinned by a +//persistent notification that declares the kinds of data it uses. The service does no measuring itself, the +//MeasurementRun does. The service adds the notification and a partial wake lock, because a foreground service +//alone does not keep the CPU awake once the screen turns off. A run that ends while nobody watches keeps the +//service, with a notification saying so, so Android does not reclaim the process and its unsaved data before +//the user returns. public class MeasurementService extends Service { private static final String TAG = "MeasurementService"; private static final String CHANNEL_ID = "measurement"; - private static final int NOTIFICATION_ID = 1; - private static final String EXTRA_TITLE = "title"; + static final int NOTIFICATION_ID = 1; private static final String EXTRA_TYPES = "types"; private static final String ACTION_STOP = "de.rwth_aachen.phyphox.action.STOP_MEASUREMENT"; + private static final String ACTION_CLOSE = "de.rwth_aachen.phyphox.action.CLOSE_MEASUREMENT"; + private static final String ACTION_REPOST = "de.rwth_aachen.phyphox.action.REPOST_MEASUREMENT"; - //The activity that owns the running measurement, so the notification's stop action and a swiped-away task can end it - private static WeakReference owner = new WeakReference<>(null); - private static boolean running = false; + //Main thread only. The run the service keeps alive, and how far its start has come: after + //startForegroundService the service must call startForeground before it stops, or Android kills the app, + //so an end that arrives first waits for the promotion. + private static MeasurementRun run = null; + private static boolean promoted = false; + private static boolean endPending = false; + private static MeasurementService instance = null; private PowerManager.WakeLock wakeLock = null; @@ -56,45 +63,56 @@ static int typesFor(PhyphoxExperiment experiment) { return types; } - //Called when a measurement starts while the activity is visible, which the location and microphone types require - static void start(Experiment activity, PhyphoxExperiment experiment) { - owner = new WeakReference<>(activity); - Intent intent = new Intent(activity, MeasurementService.class); - intent.putExtra(EXTRA_TITLE, experiment.title); - intent.putExtra(EXTRA_TYPES, typesFor(experiment)); + //Called for a run that starts or counts down while its screen is visible, which the location and microphone + //types require. False if Android refuses: the run then lasts only while it is seen, as without the setting. + static boolean start(Context context, MeasurementRun forRun) { + Intent intent = new Intent(context, MeasurementService.class); + intent.putExtra(EXTRA_TYPES, typesFor(forRun.experiment)); try { - ContextCompat.startForegroundService(activity, intent); - running = true; + ContextCompat.startForegroundService(context, intent); } catch (Exception e) { - //Android 12+ refuses starts from the background. The run then only lasts while the screen is visible, as without the setting. + //Android 12+ refuses starts from the background Log.e(TAG, "Could not start the foreground service: " + e.getMessage()); - running = false; + return false; } + run = forRun; + endPending = false; + return true; } - //Called by the activity whose measurement ends. Another Experiment activity (a second experiment opened over - //a run that continues in the background) must not end a service it does not own. - static void stop(Experiment activity) { - Experiment current = owner.get(); - if (current != null && current != activity) + //Called by a run that no longer needs the service + static void end(Context context, MeasurementRun forRun) { + if (run != forRun) + return; //Another run has taken the service over + run = null; + if (!promoted) { + endPending = true; //onStartCommand stops right after its startForeground return; - end(activity); + } + promoted = false; + context.stopService(new Intent(context, MeasurementService.class)); } - private static void end(Context context) { - running = false; - owner.clear(); - context.stopService(new Intent(context, MeasurementService.class)); + //The run changed between counting down, measuring and ended: the notification and the wake lock follow + static void update(MeasurementRun forRun) { + if (run == forRun && promoted && instance != null) + instance.refresh(); } - //True between start and stop for the activity that started it, i.e. while its running measurement may continue in the background - static boolean isRunningFor(Experiment activity) { - return running && owner.get() == activity; + //Back to the run: the Experiment activity showing it comes to the front (CLEAR_TOP with SINGLE_TOP delivers + //onNewIntent instead of creating another), or a new one attaches to the run when its screen is gone + static Intent returnIntent(Context context, MeasurementRun forRun) { + Intent intent = new Intent(forRun.source); + intent.setClass(context, Experiment.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.putExtra(Experiment.EXTRA_RETURN_TO_RUN, true); + return intent; } @Override public void onCreate() { super.onCreate(); + instance = this; NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && manager != null) { NotificationChannel channel = new NotificationChannel(CHANNEL_ID, getString(R.string.backgroundMeasurementChannel), NotificationManager.IMPORTANCE_LOW); @@ -105,51 +123,71 @@ public void onCreate() { wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "phyphox:measurement"); } - @SuppressLint({"WakelockTimeout", "InlinedApi"}) //A measurement has no bounded duration; the lock ends with the service + @SuppressLint("InlinedApi") @Override public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null && ACTION_STOP.equals(intent.getAction())) { - stopMeasurement(); + String action = intent != null ? intent.getAction() : null; + if (action != null) { + //From the notification + MeasurementRun current = run; + if (current != null) { + if (ACTION_STOP.equals(action)) + current.stop(); + else if (ACTION_CLOSE.equals(action)) + current.release(); + else if (ACTION_REPOST.equals(action) && promoted) + refresh(); //Android 14+ lets the user swipe it away, but a running measurement needs its stop button + } else if (!endPending) + stopSelf(); //Started only for this, nothing to keep return START_NOT_STICKY; } - String title = intent != null ? intent.getStringExtra(EXTRA_TITLE) : null; + int types = intent != null ? intent.getIntExtra(EXTRA_TYPES, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) : ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE; try { - ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(title), types); + ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(), types); } catch (Exception e) { //A type whose prerequisite is not met (for example location services turned off) is refused with a - //SecurityException. The run then only lasts while the screen is visible, as without the setting. + //SecurityException. The run then only lasts while it is seen, as without the setting. Log.e(TAG, "Could not promote the service to the foreground: " + e.getMessage()); - running = false; + MeasurementRun failed = run; + run = null; + promoted = false; + endPending = false; stopSelf(); + if (failed != null) + failed.onServiceFailed(); return START_NOT_STICKY; } - if (wakeLock != null && !wakeLock.isHeld()) - wakeLock.acquire(); + promoted = true; + if (endPending || run == null) { + //The run ended before the service got here + endPending = false; + promoted = false; + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); + stopSelf(); + return START_NOT_STICKY; + } + refreshWakeLock(); return START_NOT_STICKY; } - //The notification's stop action and a swiped-away task end the measurement through its owner; without an owner only the service ends - private void stopMeasurement() { - Experiment activity = owner.get(); - if (activity != null) - activity.stopMeasurement(); //Stops this service as well - else - end(this); - } - - @Override - public void onTaskRemoved(Intent rootIntent) { - stopMeasurement(); - super.onTaskRemoved(rootIntent); - } - @Override public void onDestroy() { if (wakeLock != null && wakeLock.isHeld()) wakeLock.release(); ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - running = false; + //Only the service's own state: a start for the next run may already be on its way to a new instance + if (instance == this) { + instance = null; + if (promoted) { + //Ended by Android, not by the run + MeasurementRun lost = run; + run = null; + promoted = false; + if (lost != null) + lost.onServiceFailed(); + } + } super.onDestroy(); } @@ -159,31 +197,52 @@ public IBinder onBind(Intent intent) { return null; } - private Notification buildNotification(String title) { + private void refresh() { + //Without the permission (Android 13+) the service runs on, listed only in the system's task manager + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, buildNotification()); + refreshWakeLock(); + } + + @SuppressLint("WakelockTimeout") //A measurement has no bounded duration; the lock ends with it + private void refreshWakeLock() { + if (wakeLock == null) + return; + boolean needed = run != null && run.isActive(); + if (needed && !wakeLock.isHeld()) + wakeLock.acquire(); + else if (!needed && wakeLock.isHeld()) + wakeLock.release(); + } + + private PendingIntent action(String action, int requestCode, int flags) { + Intent intent = new Intent(this, MeasurementService.class).setAction(action); + return PendingIntent.getService(this, requestCode, intent, flags); + } + + private Notification buildNotification() { + MeasurementRun current = run; + boolean ended = current != null && current.isFinishedUnseen(); + String title = current != null ? current.experiment.title : ""; int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); - //Tapping returns to the experiment the way the launcher icon does: the existing task comes to the - //front without creating a second Experiment activity (which would start a second remote server) - Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName()); - PendingIntent open = null; - if (launch != null) { - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); - open = PendingIntent.getActivity(this, 0, launch, flags); - } - Intent stopIntent = new Intent(this, MeasurementService.class).setAction(ACTION_STOP); - PendingIntent stop = PendingIntent.getService(this, 1, stopIntent, flags); NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_phyphox_transparent) .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.backgroundMeasurementNotification, title != null ? title : "")) + .setContentText(getString(ended ? R.string.backgroundMeasurementEnded : R.string.backgroundMeasurementNotification, title)) .setOngoing(true) .setOnlyAlertOnce(true) .setSilent(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .addAction(0, getString(R.string.stop), stop); - if (open != null) - builder.setContentIntent(open); + //Swiped away (Android 14+): a running measurement shows it again, an ended one lets the process go + .setDeleteIntent(action(ended ? ACTION_CLOSE : ACTION_REPOST, 2, flags)); + if (current != null) + builder.setContentIntent(PendingIntent.getActivity(this, 0, returnIntent(this, current), flags)); + if (ended) + builder.addAction(0, getString(R.string.close), action(ACTION_CLOSE, 3, flags)); + else + builder.addAction(0, getString(R.string.stop), action(ACTION_STOP, 1, flags)); return builder.build(); } } diff --git a/app/src/main/java/de/rwth_aachen/phyphox/NetworkConnection/NetworkConnection.java b/app/src/main/java/de/rwth_aachen/phyphox/NetworkConnection/NetworkConnection.java index 4bd7fd29..89065640 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/NetworkConnection/NetworkConnection.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/NetworkConnection/NetworkConnection.java @@ -352,15 +352,18 @@ public void requestFinished(NetworkService.ServiceResult result) { final android.app.Activity activity = activityRef != null ? activityRef.get() : null; if (activity != null && de.rwth_aachen.phyphox.helper.Helper.needsLocalNetworkPermission(activity)) { displayErrorMessage("Network error: No connection to network service. " + activity.getResources().getString(R.string.localNetworkDeniedHint)); - if (!localNetworkPermissionRequested) { - localNetworkPermissionRequested = true; - mainHandler.post(new Runnable() { - @Override - public void run() { - androidx.core.app.ActivityCompat.requestPermissions(activity, new String[]{android.Manifest.permission.ACCESS_LOCAL_NETWORK}, de.rwth_aachen.phyphox.Experiment.REQUEST_LOCAL_NETWORK_CONNECTIONS); - } - }); - } + mainHandler.post(new Runnable() { + @Override + public void run() { + //Android drops a request from a screen in the background (a run that goes on without + //it), which must not use up the one attempt: a later failure on screen asks + if (localNetworkPermissionRequested || !(activity instanceof androidx.lifecycle.LifecycleOwner) + || !((androidx.lifecycle.LifecycleOwner) activity).getLifecycle().getCurrentState().isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) + return; + localNetworkPermissionRequested = true; + androidx.core.app.ActivityCompat.requestPermissions(activity, new String[]{android.Manifest.permission.ACCESS_LOCAL_NETWORK}, de.rwth_aachen.phyphox.Experiment.REQUEST_LOCAL_NETWORK_CONNECTIONS); + } + }); } else { displayErrorMessage("Network error: No connection to network service."); } diff --git a/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxExperiment.java b/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxExperiment.java index 59a46a9a..dd61b0c5 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxExperiment.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxExperiment.java @@ -475,6 +475,23 @@ public void stopAllIO() { } + //Closes the connections to network services, Bluetooth devices and the depth cameras when the experiment's + //screen goes away (or its run ends without one). Loading the experiment again reestablishes them. + public void closeConnections() { + if (!loaded) + return; + for (NetworkConnection networkConnection : networkConnections) { + networkConnection.disconnect(); + networkConnection.specificAddress = null; + } + for (BluetoothInput bti : bluetoothInputs) + bti.closeConnection(); + for (BluetoothOutput bto : bluetoothOutputs) + bto.closeConnection(); + if (depthInput != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + depthInput.stopCameras(); + } + //Helper to start all I/O of this experiment (i.e. when it should be started) public void startAllIO() throws Bluetooth.BluetoothException, DepthInput.DepthInputException { diff --git a/app/src/main/java/de/rwth_aachen/phyphox/RemoteServer.java b/app/src/main/java/de/rwth_aachen/phyphox/RemoteServer.java index 926cba39..68f2dc1c 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/RemoteServer.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/RemoteServer.java @@ -932,7 +932,7 @@ public int handleGet(Request request, Response response) throws IOException { //Measuring? sb.append("\", \"measuring\":"); - sb.append(callActivity.measuring); + sb.append(callActivity.isMeasuring()); //Timed run? sb.append(", \"timedRun\":"); @@ -940,7 +940,7 @@ public int handleGet(Request request, Response response) throws IOException { //Countdown state sb.append(", \"countDown\":"); - sb.append(callActivity.millisUntilFinished); + sb.append(callActivity.countdownMillis()); sb.append("\n}\n}\n"); //Done. Build a string and return it as usual diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7eb8dc13..9c48f7d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -379,9 +379,12 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND Proximity lock Disable the screen when the proximity sensor is triggered to avoid unintended inputs for example in a pocket. Keep measuring in the background - Continue a running measurement when you leave phyphox or turn off the screen. A notification shows while it runs and lets you stop it. Camera experiments still pause. + Continue a running measurement when you leave phyphox, swipe it away or turn off the screen. A notification shows while it runs and lets you stop it. Camera experiments still pause. Running measurement Measuring in the background: %1$s + Measurement ended in the background: %1$s + %1$s is still measuring in the background. Stop it and start this measurement? + The measurement of %1$s ended in the background and has not been looked at. Close it and start this measurement? Remote access port Default file names Template for default file names when exporting data, sharing a screenshot or saving a state. Available placeholders:\n{title} – experiment title\n{date} – current date\n{time} – current time\n{startDate} – date at the start of the measurement\n{startTime} – time at the start of the measurement\n{duration} – measurement duration diff --git a/app/src/test/java/de/rwth_aachen/phyphox/Bluetooth/BluetoothCommandTest.kt b/app/src/test/java/de/rwth_aachen/phyphox/Bluetooth/BluetoothCommandTest.kt index b4eedb5e..9cdbe207 100644 --- a/app/src/test/java/de/rwth_aachen/phyphox/Bluetooth/BluetoothCommandTest.kt +++ b/app/src/test/java/de/rwth_aachen/phyphox/Bluetooth/BluetoothCommandTest.kt @@ -7,6 +7,7 @@ import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.lang.ref.WeakReference import java.util.Vector // phyphox-test: ble-command-dispatch @@ -51,6 +52,7 @@ class BluetoothCommandTest { fun aNotificationReachesTheActivityAsACommandAndUnknownOnesDoNot() { val activity = Robolectric.buildActivity(CapturingActivity::class.java).create().get() val device = Bluetooth(null, BluetoothNameFilter("sim", null), null, null, false, activity, activity, Vector()) + device.commandDelegate = WeakReference(activity) device.handleCommand(byteArrayOf(0x01)) device.handleCommand(byteArrayOf(0x03)) device.handleCommand(byteArrayOf()) @@ -58,4 +60,17 @@ class BluetoothCommandTest { device.handleCommand(byteArrayOf(0xF0.toByte())) assertThat(activity.received).containsExactly(BluetoothCommand.START, BluetoothCommand.CLEAR_ALL, BluetoothCommand.STATUS).inOrder() } + + //A run can outlive the screen that loaded the experiment: the commands go to the screen that shows the run now + @Test + fun commandsGoToTheScreenOfTheRunNotTheOneThatLoadedTheDevice() { + val loader = Robolectric.buildActivity(CapturingActivity::class.java).create().get() + val current = Robolectric.buildActivity(CapturingActivity::class.java).create().get() + val device = Bluetooth(null, BluetoothNameFilter("sim", null), null, null, false, loader, loader, Vector()) + device.handleCommand(byteArrayOf(0x01)) + device.commandDelegate = WeakReference(current) + device.handleCommand(byteArrayOf(0x00)) + assertThat(loader.received).isEmpty() + assertThat(current.received).containsExactly(BluetoothCommand.PAUSE) + } } From 63ee09e0d71aa6ea2909485724f2ed5d623d144f Mon Sep 17 00:00:00 2001 From: Vince Jankovics Date: Tue, 13 Oct 2026 10:15:36 +0100 Subject: [PATCH 3/4] LifecycleTest: count only phyphox's own service record dumpsys activity services prints the "Last ANR service" of any app whatever the filter. After a SystemUI ANR on a slow first emulator boot, that record made every "the service outlived the run" check fail (6 of 16), as any ServiceRecord counted as the measurement service. The checks now look for a ServiceRecord of the app's own MeasurementService and read isForeground from that record only. Verified on the API 36 emulator with a SystemUI ANR record present for the whole run. --- .../de/rwth_aachen/phyphox/LifecycleTest.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java index 84fa5b7c..ad3a2037 100644 --- a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java +++ b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java @@ -12,6 +12,7 @@ import android.app.ActivityManager; import android.app.Notification; import android.app.NotificationManager; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.os.Build; @@ -37,6 +38,8 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; +import java.util.regex.Matcher; +import java.util.regex.Pattern; // phyphox-test: lifecycle //A running experiment under rotation, recreation, backgrounding, removal of its screen, a second experiment @@ -130,18 +133,26 @@ private boolean runEndedUnseen() { return onMain(() -> MeasurementRun.current() != null && MeasurementRun.current().isFinishedUnseen()); } - //The system's record of the service, not the app's own bookkeeping + //The system's record of the service, not the app's own bookkeeping: the part of the dump about phyphox's + //MeasurementService, or null. The dump also holds the "Last ANR service" of any app whatever the filter (a + //SystemUI ANR on a slow first boot), so only a ServiceRecord of this very component counts. private String serviceRecord() throws Exception { - return device().executeShellCommand("dumpsys activity services MeasurementService"); + String dump = device().executeShellCommand("dumpsys activity services MeasurementService"); + String component = new ComponentName(getInstrumentation().getTargetContext(), MeasurementService.class).flattenToShortString(); + Matcher own = Pattern.compile("ServiceRecord\\{\\p{XDigit}+ u\\d+ " + Pattern.quote(component) + "[ }]").matcher(dump); + if (!own.find()) + return null; + int next = dump.indexOf("ServiceRecord{", own.end()); + return dump.substring(own.start(), next < 0 ? dump.length() : next); } private boolean serviceRunning() throws Exception { - return serviceRecord().contains("ServiceRecord{"); + return serviceRecord() != null; } private boolean serviceInForeground() throws Exception { String record = serviceRecord(); - return record.contains("ServiceRecord{") && record.contains("isForeground=true"); + return record != null && record.contains("isForeground=true"); } private String notificationText() { From 1eef9e1d106c7947ccbbd408670ad63a537a1e5c Mon Sep 17 00:00:00 2001 From: Vince Jankovics Date: Tue, 13 Oct 2026 12:17:40 +0100 Subject: [PATCH 4/4] Release a run's sensors whichever screen stops it A screen that takes a run over (recreated by a configuration change, or opened from the notification after the run's own screen was gone) attached its own SensorManager to the experiment's sensor inputs. Each Context has its own SensorManager and a listener can only be unregistered through the one that registered it, so a stop from that screen left the sensors on. Found on a Galaxy S23: after a run stopped that way the accelerometer stayed on at 416 Hz, still appending to the run's data. Experiment now takes the application context's SensorManager and LocationManager, which every screen shares. LifecycleTest gains two tests on accelerometer.phyphox that stop a run from a recreated screen and from the screen of the notification, then look for a connection of phyphox's SensorInput in dumpsys sensorservice. Both failed before the change and pass after, on the API 36 emulator and on the phone. --- .../de/rwth_aachen/phyphox/LifecycleTest.java | 71 +++++++++++++++++-- .../de/rwth_aachen/phyphox/Experiment.java | 10 ++- .../de/rwth_aachen/phyphox/PhyphoxFile.java | 3 +- .../phyphox/CorpusTestEnvironment.java | 6 +- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java index ad3a2037..06da5e6e 100644 --- a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java +++ b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java @@ -43,12 +43,13 @@ // phyphox-test: lifecycle //A running experiment under rotation, recreation, backgrounding, removal of its screen, a second experiment -//and quick start/stop, observed through the remote API, the run itself and the system's service records. +//and quick start/stop, observed through the remote API, the run itself and the system's service and sensor records. @RunWith(AndroidJUnit4.class) public class LifecycleTest { private static final int PORT = 8080; private static final String FIXTURE = "values.phyphox"; + private static final String SENSOR_EXPERIMENT = "accelerometer.phyphox"; //The fixture uses no sensor private interface Check { boolean holds() throws Exception; @@ -175,6 +176,23 @@ private boolean proximityLockHeld() throws Exception { return power.substring(start, end < 0 ? power.length() : end).contains("'phyphox:measuring'"); } + //A sensor that phyphox listens to now: the dump lists each open connection by its listener's class, followed by a + //line per sensor it has enabled. Its log of earlier registrations names the listener too, but not in this form. + private boolean sensorsHeld() throws Exception { + String dump = device().executeShellCommand("dumpsys sensorservice"); + return Pattern.compile("^\\t " + Pattern.quote(SensorInput.class.getName()) + " \\|.*\\n\\t .* 0x\\p{XDigit}{8} \\|", + Pattern.MULTILINE).matcher(dump).find(); + } + + //What Recents trimming or a swipe does to the task of a screen + private void removeTask(Experiment screen) { + int taskId = screen.getTaskId(); + ActivityManager manager = getInstrumentation().getTargetContext().getSystemService(ActivityManager.class); + for (ActivityManager.AppTask task : manager.getAppTasks()) + if (task.getTaskInfo().taskId == taskId) + task.finishAndRemoveTask(); + } + private Notification.Action notificationAction(int index) { NotificationManager manager = getInstrumentation().getTargetContext().getSystemService(NotificationManager.class); for (StatusBarNotification notification : manager.getActiveNotifications()) @@ -477,11 +495,7 @@ public void aRunGoesOnWhenAndroidRemovesItsScreen() throws Exception { goHome(); Thread.sleep(1500); - int taskId = first.getTaskId(); - ActivityManager manager = getInstrumentation().getTargetContext().getSystemService(ActivityManager.class); - for (ActivityManager.AppTask task : manager.getAppTasks()) - if (task.getTaskInfo().taskId == taskId) - task.finishAndRemoveTask(); + removeTask(first); assertTrue("the screen survived the removal of its task", eventually(() -> experimentActivities().isEmpty())); Thread.sleep(1000); assertTrue("the run ended with its screen", runMeasuring()); @@ -501,6 +515,51 @@ public void aRunGoesOnWhenAndroidRemovesItsScreen() throws Exception { assertTrue("the foreground service outlived the run", eventually(() -> !serviceRunning())); } + //A screen that takes over a run must release its sensors when it stops it. Found on a phone: started, Home, the + //task swiped away, back through the notification and stopped there, the accelerometer stayed on. + @Test + public void aRunStoppedFromTheScreenOfTheNotificationReleasesItsSensors() throws Exception { + assumeTrue("TaskInfo.taskId", Build.VERSION.SDK_INT >= 29); + setBackgroundMeasurement(true); + Experiment first = FixtureExperiment.launchAsset(SENSOR_EXPERIMENT); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + assertTrue("the run listens to no sensor", eventually(this::sensorsHeld)); + + goHome(); + Thread.sleep(1500); + removeTask(first); + assertTrue("the screen survived the removal of its task", eventually(() -> experimentActivities().isEmpty())); + tapNotification(); + FixtureExperiment.awaitLoaded(); + awaitApi(); + + api("/control?cmd=stop"); + awaitMeasuring(false); + assertTrue("the sensors outlived the run", eventually(() -> !sensorsHeld())); + } + + @Test + public void aRunStoppedFromItsRecreatedScreenReleasesItsSensors() throws Exception { + Experiment first = FixtureExperiment.launchAsset(SENSOR_EXPERIMENT); + awaitApi(); + api("/control?cmd=start"); + awaitMeasuring(true); + assertTrue("the run listens to no sensor", eventually(this::sensorsHeld)); + + getInstrumentation().runOnMainSync(first::recreate); + assertTrue("the screen was not recreated", eventually(() -> { + Experiment current = FixtureExperiment.activity(); + return current != null && current != first; + })); + awaitApi(); + + api("/control?cmd=stop"); + awaitMeasuring(false); + assertTrue("the sensors outlived the run", eventually(() -> !sensorsHeld())); + } + @Test public void theNotificationReturnsToTheScreenOfTheRun() throws Exception { assumeTrue(FixtureExperiment.available(FIXTURE)); diff --git a/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java b/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java index b4d867e9..72507b72 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/Experiment.java @@ -212,7 +212,8 @@ public class Experiment extends AppCompatActivity implements View.OnClickListene //Others... private Resources res; //Helper to easily access resources - public SensorManager sensorManager; //The sensor manager + public SensorManager sensorManager; //The application's, see onCreate + public LocationManager locationManager; //The same Intent intent; //Another helper to easily access the data of the intent that triggered this activity ProgressDialog progress; //Holds a progress dialog when a file is being loaded Bundle savedInstanceState = null; //Holds the saved instance state, so it can be handled outside onCreate @@ -319,7 +320,10 @@ public void handleOnBackPressed() { intent = getIntent(); //Store the intent for easy access res = getResources(); //The same for resources - sensorManager = (SensorManager)getSystemService(SENSOR_SERVICE); //The sensor manager will probably be needed... + //The application's managers, not this screen's: a run outlives the screen that started it, and a sensor listener + //can only be unregistered through the SensorManager that registered it, of which each Context has its own + sensorManager = (SensorManager)getApplicationContext().getSystemService(SENSOR_SERVICE); + locationManager = (LocationManager)getApplicationContext().getSystemService(LOCATION_SERVICE); this.savedInstanceState = savedInstanceState; //Store savedInstanceState so it can be accessed after loading the experiment in a second thread setContentView(R.layout.activity_experiment); //Setup the views... @@ -718,7 +722,7 @@ public void onPageScrollStateChanged(int state) {} tabLayout.setVisibility(View.GONE); try { - experiment.init(sensorManager, (LocationManager)this.getSystemService(Context.LOCATION_SERVICE)); + experiment.init(sensorManager, locationManager); } catch (Exception e) { Toast.makeText(getApplicationContext(), e.getMessage(), Toast.LENGTH_LONG).show(); } diff --git a/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxFile.java b/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxFile.java index df71976a..a3e3dcb9 100644 --- a/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxFile.java +++ b/app/src/main/java/de/rwth_aachen/phyphox/PhyphoxFile.java @@ -12,7 +12,6 @@ import android.content.pm.PackageManager; import android.content.res.AssetManager; import android.hardware.camera2.CameraManager; -import android.location.LocationManager; import android.media.AudioFormat; import android.media.AudioRecord; import android.net.Uri; @@ -2625,7 +2624,7 @@ protected void processStartTag(String tag) throws XmlPullParserException, phypho (new ioBlockParser(xpp, experiment, parent, null, outputs, null, outputMapping, "component")).process(); //Load inputs and outputs experiment.gpsIn = new GpsInput(outputs, experiment.dataLock, experiment.experimentTimeReference); - experiment.gpsIn.attachLocationManager((LocationManager)parent.getSystemService(Context.LOCATION_SERVICE)); + experiment.gpsIn.attachLocationManager(parent.locationManager); if (!GpsInput.isAvailable(parent)) { throw new phyphoxFileException(parent.getResources().getString(R.string.sensorNotAvailableWarningText1) + " " + parent.getResources().getString(R.string.location) + " " + parent.getResources().getString(R.string.sensorNotAvailableWarningText2)); diff --git a/app/src/test/java/de/rwth_aachen/phyphox/CorpusTestEnvironment.java b/app/src/test/java/de/rwth_aachen/phyphox/CorpusTestEnvironment.java index cbf4d5a9..e6504c1f 100644 --- a/app/src/test/java/de/rwth_aachen/phyphox/CorpusTestEnvironment.java +++ b/app/src/test/java/de/rwth_aachen/phyphox/CorpusTestEnvironment.java @@ -15,6 +15,7 @@ import android.hardware.camera2.CameraManager; import android.hardware.camera2.CameraMetadata; import android.graphics.ImageFormat; +import android.location.LocationManager; import android.util.Range; import android.util.Size; @@ -224,12 +225,13 @@ public static Experiment fullyEquippedActivity() { //The depth input and the device metadata read this cache without a null check. CameraHelper.updateCameraList(cameraManager); - //Attached but not started: the parser uses the activity only as a Context and for sensorManager, - //and onCreate would start loading an experiment itself. + //Attached but not started: the parser uses the activity only as a Context and for its sensor and location + //managers, and onCreate would start loading an experiment itself. Experiment activity = Robolectric.buildActivity(Experiment.class).get(); if (activity.getBaseContext() == null) Shadows.shadowOf(activity).callAttach(new Intent()); activity.sensorManager = sensorManager; + activity.locationManager = (LocationManager) application.getSystemService(Context.LOCATION_SERVICE); return activity; }