|
| 1 | +package de.rwth_aachen.phyphox; |
| 2 | + |
| 3 | +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; |
| 4 | +import static org.junit.Assert.assertFalse; |
| 5 | +import static org.junit.Assert.assertNotNull; |
| 6 | +import static org.junit.Assert.assertTrue; |
| 7 | +import static org.junit.Assume.assumeTrue; |
| 8 | + |
| 9 | +import android.content.Context; |
| 10 | +import android.content.Intent; |
| 11 | + |
| 12 | +import androidx.test.ext.junit.runners.AndroidJUnit4; |
| 13 | +import androidx.test.platform.app.InstrumentationRegistry; |
| 14 | +import androidx.test.uiautomator.By; |
| 15 | +import androidx.test.uiautomator.StaleObjectException; |
| 16 | +import androidx.test.uiautomator.UiDevice; |
| 17 | +import androidx.test.uiautomator.UiObject2; |
| 18 | +import androidx.test.uiautomator.Until; |
| 19 | + |
| 20 | +import org.junit.Before; |
| 21 | +import org.junit.Test; |
| 22 | +import org.junit.runner.RunWith; |
| 23 | + |
| 24 | +// phyphox-test: ble-compat-arduino |
| 25 | +// phyphox-test: ble-compat-micropython |
| 26 | +//The UI half of the BLE compatibility suite, and deliberately only that half: picking a device |
| 27 | +//out of a scan has no equivalent over the remote API, so it has to be driven here, while |
| 28 | +//everything that follows - the values, the rates, what the board actually emits - is asserted |
| 29 | +//from the host against the shared expectations (phyphox-docs/tools/lab/ble.py and |
| 30 | +//fixtures/ble/scenarios.yml). Duplicating those assertions in two languages would let them |
| 31 | +//drift; this keeps them in one file and still exercises the real scan UI, which is part of what |
| 32 | +//the suite protects. |
| 33 | +// |
| 34 | +//So this test ends where the host takes over: the experiment the device offers is loaded, not |
| 35 | +//started, and serving the remote API. It asserts nothing about the data. |
| 36 | +// |
| 37 | +//The device name comes from the driver, which flashes the library examples UNMODIFIED and knows |
| 38 | +//what they advertise as: |
| 39 | +// |
| 40 | +// adb shell am instrument -e class de.rwth_aachen.phyphox.BleCompatTest \ |
| 41 | +// -e bleDevice phyphox-arduino ... |
| 42 | +// |
| 43 | +//Without that parameter there is no board to talk to and that test skips itself, which is what |
| 44 | +//happens in CI - the row needs hardware and runs in the lab. The seam check below needs none and |
| 45 | +//runs everywhere, because a suite that cannot reach the phone is worth catching before the board |
| 46 | +//is even involved. |
| 47 | +@RunWith(AndroidJUnit4.class) |
| 48 | +public class BleCompatTest { |
| 49 | + |
| 50 | + private static final String PACKAGE = "de.rwth_aachen.phyphox"; |
| 51 | + private static final int PORT = 8080; |
| 52 | + |
| 53 | + private UiDevice device() { |
| 54 | + return UiDevice.getInstance(getInstrumentation()); |
| 55 | + } |
| 56 | + |
| 57 | + private UiObject2 waitForId(String id, long timeout) { |
| 58 | + return device().wait(Until.findObject(By.res(PACKAGE + ":id/" + id)), timeout); |
| 59 | + } |
| 60 | + |
| 61 | + @Before |
| 62 | + public void quietFirstRunDialogs() { |
| 63 | + FixtureExperiment.suppressHints(); |
| 64 | + } |
| 65 | + |
| 66 | + //The precondition the whole suite rests on, and the one half of it that needs no board: an |
| 67 | + //experiment with a Bluetooth block serves the remote API when the switch is set, so the host |
| 68 | + //can reach it. The switch is applied where every experiment finishes loading |
| 69 | + //(Experiment.onExperimentLoaded), which is the same place a transferred one arrives at, so |
| 70 | + //this covers the delivered case too as far as it can be covered without hardware. |
| 71 | + @Test |
| 72 | + public void aBluetoothExperimentServesTheRemoteApiWhenTheSwitchIsSet() throws Exception { |
| 73 | + shell("setprop debug.phyphox.remote 1"); |
| 74 | + shell("setprop debug.phyphox.remotePort " + PORT); |
| 75 | + try { |
| 76 | + FixtureExperiment.launchAssetWithoutWaiting("bluetooth/Heart Rate.phyphox"); |
| 77 | + //It stops at "please pick a device" without one, which is exactly the state the host |
| 78 | + //finds a transferred experiment in before it starts it - and the API has to answer |
| 79 | + //there, not only once something is connected. |
| 80 | + long deadline = System.currentTimeMillis() + 30000; |
| 81 | + boolean answered = false; |
| 82 | + while (!answered && System.currentTimeMillis() < deadline) { |
| 83 | + answered = remoteApiAnswers(); |
| 84 | + if (!answered) |
| 85 | + Thread.sleep(500); |
| 86 | + } |
| 87 | + assertTrue("the remote API did not come up for a Bluetooth experiment although " |
| 88 | + + "debug.phyphox.remote is set - the host cannot reach a device-delivered " |
| 89 | + + "experiment either", answered); |
| 90 | + } finally { |
| 91 | + shell("setprop debug.phyphox.remote '\"\"'"); |
| 92 | + shell("setprop debug.phyphox.remotePort '\"\"'"); |
| 93 | + FixtureExperiment.close(FixtureExperiment.activity()); |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + private void shell(String command) throws Exception { |
| 98 | + device().executeShellCommand(command); |
| 99 | + } |
| 100 | + |
| 101 | + private boolean remoteApiAnswers() { |
| 102 | + try { |
| 103 | + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) |
| 104 | + new java.net.URL("http://127.0.0.1:" + PORT + "/config").openConnection(); |
| 105 | + connection.setConnectTimeout(2000); |
| 106 | + connection.setReadTimeout(2000); |
| 107 | + try (java.io.InputStream in = connection.getInputStream()) { |
| 108 | + return in.read() > 0; |
| 109 | + } finally { |
| 110 | + connection.disconnect(); |
| 111 | + } |
| 112 | + } catch (Exception e) { |
| 113 | + return false; |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + @Test |
| 118 | + public void theDeviceOffersItsExperimentAndItLoads() throws Exception { |
| 119 | + String name = InstrumentationRegistry.getArguments().getString("bleDevice"); |
| 120 | + assumeTrue("no bleDevice given - this row needs a board and runs in the lab", |
| 121 | + name != null && !name.trim().isEmpty()); |
| 122 | + name = name.trim(); |
| 123 | + |
| 124 | + //The collection, where the scan lives. |
| 125 | + Context app = getInstrumentation().getTargetContext(); |
| 126 | + Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName()); |
| 127 | + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); |
| 128 | + app.startActivity(intent); |
| 129 | + |
| 130 | + //By resource id, not by label: the labels are translated and the run may be in any |
| 131 | + //language, but the ids are the same in every build. |
| 132 | + UiObject2 fab = waitForId("newExperiment", 20000); |
| 133 | + assertNotNull("the collection did not come up with its new-experiment button", fab); |
| 134 | + fab.click(); |
| 135 | + UiObject2 bluetooth = waitForId("newExperimentBluetooth", 10000); |
| 136 | + assertNotNull("the new-experiment menu has no Bluetooth entry", bluetooth); |
| 137 | + bluetooth.click(); |
| 138 | + |
| 139 | + //The scan needs a moment to find anything, and there is deliberately more than one board |
| 140 | + //advertising: taking the first entry would pass against the wrong device, so this waits |
| 141 | + //for the name the driver asked for and for nothing else. |
| 142 | + UiObject2 entry = device().wait(Until.findObject(By.text(name)), 45000); |
| 143 | + assertNotNull("the scan did not list a device called \"" + name + "\" within 45 s - is it " |
| 144 | + + "powered and advertising?", entry); |
| 145 | + try { |
| 146 | + entry.click(); |
| 147 | + } catch (StaleObjectException e) { |
| 148 | + //The scan list rebuilds as devices come and go; look the entry up once more. |
| 149 | + entry = device().wait(Until.findObject(By.text(name)), 10000); |
| 150 | + assertNotNull("\"" + name + "\" disappeared from the scan before it could be picked", |
| 151 | + entry); |
| 152 | + entry.click(); |
| 153 | + } |
| 154 | + |
| 155 | + //A device that both offers its own experiment and matches bundled ones asks which to use. |
| 156 | + //A board running an unmodified library example offers only its own, and phyphox opens it |
| 157 | + //without asking - so this dialog is optional, and only its "load from device" choice is |
| 158 | + //the one under test. |
| 159 | + UiObject2 loadFromDevice = device().wait(Until.findObject( |
| 160 | + By.text(app.getString(R.string.newExperimentBluetoothLoadFromDevice))), 5000); |
| 161 | + if (loadFromDevice != null) |
| 162 | + loadFromDevice.click(); |
| 163 | + |
| 164 | + //The transfer runs over BLE and is slow on purpose - the experiment is chunked over a |
| 165 | + //characteristic - so this is the one wait worth being generous about. |
| 166 | + Experiment experiment = awaitLoaded(90000); |
| 167 | + assertNotNull("the experiment the device offers did not load within 90 s", experiment); |
| 168 | + assertTrue("the experiment arrived but did not parse: " + experiment.experiment.message, |
| 169 | + experiment.experiment.loaded); |
| 170 | + |
| 171 | + //Left loaded and not started: the host starts it over the remote API and does the |
| 172 | + //measuring, because that is where the shared expectations live. |
| 173 | + assertFalse("the experiment must be left for the host to start, not started here", |
| 174 | + experiment.measuring); |
| 175 | + } |
| 176 | + |
| 177 | + //FixtureExperiment.awaitLoaded has a fixed deadline and throws; the BLE transfer needs its |
| 178 | + //own, and a null lets the assertion above say what actually went wrong. |
| 179 | + private Experiment awaitLoaded(long millis) { |
| 180 | + long deadline = System.currentTimeMillis() + millis; |
| 181 | + while (System.currentTimeMillis() < deadline) { |
| 182 | + Experiment activity = FixtureExperiment.activity(); |
| 183 | + if (activity != null && activity.experiment != null && activity.experiment.loaded) |
| 184 | + return activity; |
| 185 | + try { |
| 186 | + Thread.sleep(250); |
| 187 | + } catch (InterruptedException e) { |
| 188 | + Thread.currentThread().interrupt(); |
| 189 | + break; |
| 190 | + } |
| 191 | + } |
| 192 | + return null; |
| 193 | + } |
| 194 | +} |
0 commit comments