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 f0181ee7..06da5e6e 100644 --- a/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java +++ b/app/src/androidTest/java/de/rwth_aachen/phyphox/LifecycleTest.java @@ -2,10 +2,25 @@ 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.ComponentName; +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; +import androidx.test.runner.lifecycle.Stage; import androidx.test.uiautomator.By; import androidx.test.uiautomator.UiDevice; import androidx.test.uiautomator.Until; @@ -20,34 +35,223 @@ 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; +import java.util.regex.Matcher; +import java.util.regex.Pattern; // 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 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; + } @Before 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()); + //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 '\"\"'"); 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(); + } + + //A timed run as the dialog would set it up + private void setTimedRun(Experiment activity, double startDelay, double stopDelay) { + getInstrumentation().runOnMainSync(() -> { + 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 && !found.contains(activity)) + found.add((Experiment) activity); + return found; + }); + } + + //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: 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 { + 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() != null; + } + + private boolean serviceInForeground() throws Exception { + String record = serviceRecord(); + return record != null && 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'"); + } + + //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()) + 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); } @@ -57,7 +261,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]; @@ -122,6 +326,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)); @@ -130,7 +361,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 @@ -145,6 +376,304 @@ 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); + assertTrue("the foreground service did not start with the run", eventually(this::serviceInForeground)); + + goHome(); + Thread.sleep(3000); + //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); + awaitApi(); + assertTrue("the measurement did not survive the return to the foreground", measuring()); + + api("/control?cmd=stop"); + awaitMeasuring(false); + 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); + 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()); + 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())); + } + + //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)); + 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 public void aSecondExperimentReplacesTheFirst() throws Exception { assumeTrue(FixtureExperiment.available(FIXTURE)); 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/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/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 2829661f..72507b72 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,12 +162,16 @@ 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 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 boolean startHintDismissed = false; //Remember that the user has clicked away the hint to the start button @@ -197,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. @@ -208,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 @@ -219,13 +224,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; @@ -315,11 +318,12 @@ 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... + //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... @@ -340,7 +344,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); @@ -365,23 +378,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 @@ -398,8 +423,20 @@ public void onStop() { progress = null; } - stopRemoteServer(); //Remote server should stop when the app is not active - shutdownIO(); + 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; + 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(); @@ -414,6 +451,7 @@ public void onRestart() { super.onRestart(); shutdown = false; //Deactivate shutdown variable + 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 { @@ -424,13 +462,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 goes on without its screen detaches and waits for the next Experiment activity (see MeasurementRun) + protected void onDestroy() { + 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) { @@ -652,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(); } @@ -691,11 +761,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; @@ -856,21 +945,41 @@ 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(); 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)) 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) { @@ -1210,10 +1319,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) { @@ -1268,8 +1379,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)); @@ -1565,33 +1676,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() { @@ -1624,13 +1709,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)) { @@ -1645,8 +1737,8 @@ 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 (measuring) + if (!shutdown && !viewsPaused) { + 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 @@ -1713,93 +1805,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); - - //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); @@ -1834,78 +1918,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 - - //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 @@ -1914,10 +1942,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 @@ -2050,7 +2131,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. @@ -2096,12 +2177,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; } @@ -2113,9 +2194,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) @@ -2138,7 +2220,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; } }); @@ -2200,6 +2282,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. @@ -2218,13 +2305,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() { @@ -2365,7 +2455,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 new file mode 100644 index 00000000..87a6a416 --- /dev/null +++ b/app/src/main/java/de/rwth_aachen/phyphox/MeasurementService.java @@ -0,0 +1,248 @@ +package de.rwth_aachen.phyphox; + +import android.Manifest; +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.PackageManager; +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.NotificationManagerCompat; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; + +//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"; + 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"; + + //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; + + //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 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(context, intent); + } catch (Exception e) { + //Android 12+ refuses starts from the background + Log.e(TAG, "Could not start the foreground service: " + e.getMessage()); + return false; + } + run = forRun; + endPending = false; + return true; + } + + //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; + } + promoted = false; + 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(); + } + + //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); + manager.createNotificationChannel(channel); + } + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (powerManager != null) + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "phyphox:measurement"); + } + + @SuppressLint("InlinedApi") + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + 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; + } + + 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(), 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 it is seen, as without the setting. + Log.e(TAG, "Could not promote the service to the foreground: " + e.getMessage()); + MeasurementRun failed = run; + run = null; + promoted = false; + endPending = false; + stopSelf(); + if (failed != null) + failed.onServiceFailed(); + return START_NOT_STICKY; + } + 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; + } + + @Override + public void onDestroy() { + if (wakeLock != null && wakeLock.isHeld()) + wakeLock.release(); + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); + //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(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + 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); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_phyphox_transparent) + .setContentTitle(getString(R.string.app_name)) + .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) + //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/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/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/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..9c48f7d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -378,6 +378,13 @@ 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, 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/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/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) + } } 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; } 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