Skip to content

Commit f3e81f9

Browse files
Merge branch 'master' into fix/report-keep-exclude-after-boot
2 parents 71717c6 + 88fbf34 commit f3e81f9

27 files changed

Lines changed: 723 additions & 816 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/bin/sh
2+
# Runs the instrumented suite inside the emulator-runner's script step.
3+
#
4+
# This lives in a file rather than inline in ci.yml because
5+
# reactivecircus/android-emulator-runner executes each line of an inline script in
6+
# its own shell. Any multi-line construct therefore breaks: an inline `if` fails
7+
# with "Syntax error: end of file unexpected (expecting fi)" because only the first
8+
# line reaches the shell. Invoking one script keeps it to a single line.
9+
#
10+
# Usage: run-instrumented-tests.sh <api-level>
11+
set -e
12+
13+
API_LEVEL="$1"
14+
if [ -z "$API_LEVEL" ]; then
15+
echo "usage: $0 <api-level>" >&2
16+
exit 2
17+
fi
18+
19+
if [ "$API_LEVEL" -ge 36 ]; then
20+
# The runner's AVD is phone-sized, and the API 36 orientation change only
21+
# applies from 600dp of smallest width up. Without this the large-screen tests
22+
# report as skipped and the job goes green having covered nothing.
23+
# 1600dp / (240/160) = 1066dp of smallest width, in landscape.
24+
echo "API $API_LEVEL: forcing a large-screen configuration"
25+
adb shell wm size 2560x1600
26+
adb shell wm density 240
27+
adb shell wm set-ignore-orientation-request true
28+
# Informational, and must not fail the run if the line moves between releases.
29+
adb shell dumpsys window displays | grep -m1 ignoreOrientationRequest || true
30+
else
31+
# Left phone-sized on purpose: this level is here to cover the code that only
32+
# runs below Android 13, where BackNavigationCompat falls back to onBackPressed.
33+
echo "API $API_LEVEL: keeping the default phone configuration"
34+
fi
35+
36+
./gradlew connectedDebugAndroidTest

‎.github/workflows/ci.yml‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
# Android CI Pipeline
2-
# Runs 3 jobs in parallel:
2+
# Runs in parallel:
33
# - Lint: static analysis (~2 min)
44
# - Unit Tests: Robolectric tests on JVM, no emulator needed (~1 min)
5-
# - Instrumented Tests: runs on Android emulator (~8-10 min)
5+
# - Instrumented Tests: one emulator per API level in the matrix (~8-10 min each)
66
# Total time is the slowest job (~10 min).
77
# If the emulator job is unstable in CI, you can safely disable it
88
# and rely on Robolectric tests only.
9+
#
10+
# The instrumented job runs on two API levels on purpose. API 36 is what the app
11+
# targets, and it is where the large-screen orientation change applies. API 30 is
12+
# kept because part of the code only runs below Android 13 — BackNavigationCompat
13+
# falls back to onBackPressed there — so dropping it would leave that path untested.
914

1015
name: CI
1116

@@ -85,8 +90,12 @@ jobs:
8590
path: app/build/reports/tests/testDebugUnitTest/
8691

8792
instrumented-test:
88-
name: Instrumented Tests (Emulator)
93+
name: Instrumented Tests (API ${{ matrix.api-level }})
8994
runs-on: ubuntu-latest
95+
strategy:
96+
fail-fast: false
97+
matrix:
98+
api-level: [30, 36]
9099
steps:
91100
- uses: actions/checkout@v4
92101

@@ -117,13 +126,17 @@ jobs:
117126
- name: Run instrumented tests
118127
uses: reactivecircus/android-emulator-runner@v2
119128
with:
120-
api-level: 30
129+
api-level: ${{ matrix.api-level }}
121130
arch: x86_64
122-
script: ./gradlew connectedDebugAndroidTest
131+
# One line on purpose: this action runs each line of an inline script in its
132+
# own shell, so a multi-line `if` here fails to parse. The script forces a
133+
# large-screen configuration on API 36 and leaves older levels phone-sized.
134+
script: .github/scripts/run-instrumented-tests.sh ${{ matrix.api-level }}
123135

124136
- name: Upload instrumented test results
125137
if: always()
126138
uses: actions/upload-artifact@v4
127139
with:
128-
name: instrumented-test-results
140+
# Per API level: upload-artifact@v4 rejects two uploads under one name.
141+
name: instrumented-test-results-api${{ matrix.api-level }}
129142
path: app/build/reports/androidTests/connected/

‎app/build.gradle‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,21 @@ task checkstyle(type: Checkstyle) {
1313

1414
android {
1515
namespace 'com.prey'
16-
compileSdk = 35
16+
compileSdk = 36
1717

1818
defaultConfig {
1919
applicationId = "com.prey"
2020
minSdk = 21
2121

22-
targetSdk = 35
22+
targetSdk = 36
2323

2424
versionCode = 417
2525
versionName = '2.6.20'
2626

2727
multiDexEnabled = true
2828

2929
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
30-
buildConfigField("int", "COMPILE_SDK_VERSION", "35")
30+
buildConfigField("int", "COMPILE_SDK_VERSION", "36")
3131
buildConfigField("String", "MDM_DEBUG_URL", "\"\"")
3232
applySharedPreyBuildConfig(delegate)
3333
}

‎app/build.gradle.internal‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ apply plugin: 'com.google.gms.google-services'
33
apply plugin: 'com.google.firebase.crashlytics'
44
apply from: "${projectDir}/build-config-fields.gradle"
55

6+
// IMPORTANT: this flavor stays on SDK 33 on purpose. The wipe capability we rely
7+
// on is blocked at higher target API levels, so do NOT bump compileSdk/targetSdk
8+
// here when raising them in build.gradle (Play) or build.gradle.dev. This build
9+
// is distributed through an internal process, not Google Play, so it is not
10+
// subject to Play's target API level requirement.
611
android {
712
namespace = 'com.prey'
813
compileSdk = 33
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/*******************************************************************************
2+
* Created by Patricio Jofré
3+
* Copyright 2026 Prey Inc. All rights reserved.
4+
* License: GPLv3
5+
* Full license at "/LICENSE"
6+
******************************************************************************/
7+
package com.prey.activities;
8+
9+
import android.app.Activity;
10+
import android.app.Instrumentation;
11+
import android.content.Context;
12+
import android.content.res.Configuration;
13+
import android.content.res.Resources;
14+
import android.os.Build;
15+
import android.view.KeyEvent;
16+
17+
import com.prey.R;
18+
19+
import org.junit.Test;
20+
import org.junit.runner.RunWith;
21+
22+
import java.lang.reflect.Field;
23+
import java.util.ArrayList;
24+
import java.util.List;
25+
26+
import androidx.test.core.app.ActivityScenario;
27+
import androidx.test.core.app.ApplicationProvider;
28+
import androidx.test.ext.junit.runners.AndroidJUnit4;
29+
import androidx.test.platform.app.InstrumentationRegistry;
30+
31+
import static org.junit.Assert.assertEquals;
32+
import static org.junit.Assert.assertNotNull;
33+
import static org.junit.Assert.assertTrue;
34+
import static org.junit.Assert.fail;
35+
import static org.junit.Assume.assumeTrue;
36+
37+
/**
38+
* On-device checks for the two behaviours Android 16 (API 36) changed for this app.
39+
* <p>
40+
* Run this on a display whose smallest width is at least 600dp — a tablet or foldable
41+
* AVD — because that is where API 36 stops honouring the portrait lock every activity
42+
* here requests. On a phone-sized AVD the orientation assertions still pass but prove
43+
* much less, so {@link #displayIsLargeEnoughToBeMeaningful()} reports the configuration
44+
* the run actually exercised.
45+
* <p>
46+
* These live in androidTest rather than test because they need the real resource
47+
* resolution and the real back dispatch, and because non-exported activities cannot be
48+
* started from the adb shell — instrumentation runs as the app's own UID, so it can.
49+
*/
50+
@RunWith(AndroidJUnit4.class)
51+
public class LargeScreenBehaviorInstrumentedTest {
52+
53+
private static final long BACK_NAVIGATION_TIMEOUT_MS = 8000;
54+
55+
// =========================================================================
56+
// Configuration the run is actually exercising
57+
// =========================================================================
58+
59+
/**
60+
* Asserts that the portrait lock really is being ignored, which is the premise every
61+
* other test here rests on.
62+
* <p>
63+
* PanelWebActivity declares {@code screenOrientation="portrait"} in the manifest, so
64+
* on a large screen running API 36 it should come up in landscape anyway. If it comes
65+
* up portrait, the platform is still honouring the lock and this run is not exercising
66+
* the change at all.
67+
* <p>
68+
* The preconditions are assumptions rather than assertions on purpose: below API 36, on
69+
* a phone-sized display, or with the device in portrait, this reports as skipped instead
70+
* of passed, so a green suite cannot be mistaken for coverage it did not provide. The
71+
* layout sweeps below still run either way — plain landscape is enough to catch the
72+
* welcomebatch class of bug, on any API level.
73+
*/
74+
@Test
75+
public void portraitLockIsIgnoredOnThisDisplay() {
76+
Configuration config = resources().getConfiguration();
77+
int smallestWidthDp = config.smallestScreenWidthDp;
78+
79+
// Below API 36 the platform still honours the lock, so the assertion below would be
80+
// wrong rather than merely uninformative. This matters for CI, which runs the suite
81+
// on an older API level too, to cover BackNavigationCompat's pre-Android 13 path.
82+
assumeTrue(
83+
String.format("API %d is below 36, where the portrait lock is still honoured",
84+
Build.VERSION.SDK_INT),
85+
Build.VERSION.SDK_INT >= 36);
86+
assumeTrue(
87+
String.format("smallestScreenWidthDp=%d is below 600 — run on a tablet AVD to "
88+
+ "exercise the API 36 orientation change", smallestWidthDp),
89+
smallestWidthDp >= 600);
90+
assumeTrue(
91+
"Device is in portrait, so an ignored portrait lock is indistinguishable from "
92+
+ "an honoured one; rotate the AVD to landscape",
93+
config.orientation == Configuration.ORIENTATION_LANDSCAPE);
94+
95+
try (ActivityScenario<PanelWebActivity> scenario =
96+
ActivityScenario.launch(PanelWebActivity.class)) {
97+
scenario.onActivity(activity -> assertEquals(
98+
String.format("PanelWebActivity asks for portrait, but on a %ddp display "
99+
+ "running API 36 that request must be ignored",
100+
smallestWidthDp),
101+
Configuration.ORIENTATION_LANDSCAPE,
102+
activity.getResources().getConfiguration().orientation));
103+
}
104+
}
105+
106+
// =========================================================================
107+
// Resource resolution in the configuration the device is actually in
108+
// =========================================================================
109+
110+
@Test
111+
public void welcomeBatchLayoutResolvesInThisConfiguration() {
112+
assertNotNull(
113+
"welcomebatch must resolve in the current configuration; it used to exist "
114+
+ "only under -port qualifiers, which threw once API 36 let large "
115+
+ "screens ignore WelcomeBatchActivity's portrait lock",
116+
resources().getLayout(R.layout.welcomebatch)
117+
);
118+
}
119+
120+
/**
121+
* Name prefixes of layouts that come from AppCompat, Material and other AndroidX
122+
* libraries. {@code R.layout} holds the merged resources of every dependency, and some
123+
* library layouts are scoped to one configuration on purpose —
124+
* {@code material_clock_period_toggle_land} exists only for landscape, and the library
125+
* only reaches for it there — so sweeping them reports failures that are not ours.
126+
* <p>
127+
* Keep in sync with the copy in {@code LayoutConfigurationCoverageRobolectricTest},
128+
* which documents how the list was verified against the dependency set.
129+
*/
130+
private static final String[] THIRD_PARTY_LAYOUT_PREFIXES = {
131+
"abc_", "m3_", "material_", "mtrl_", "design_", "notification_",
132+
"select_dialog", "support_", "preference", "browser_actions",
133+
"custom_dialog", "expand_button", "image_frame", "test_",
134+
"fingerprint_dialog", "ime_",
135+
};
136+
137+
@Test
138+
public void everyLayoutResolvesInThisConfiguration() {
139+
Resources resources = resources();
140+
List<String> missing = new ArrayList<>();
141+
int checked = 0;
142+
int skipped = 0;
143+
144+
for (Field field : R.layout.class.getFields()) {
145+
int id;
146+
try {
147+
id = field.getInt(null);
148+
} catch (IllegalAccessException e) {
149+
continue;
150+
}
151+
if (isThirdPartyLayout(field.getName())) {
152+
skipped++;
153+
continue;
154+
}
155+
checked++;
156+
try {
157+
resources.getLayout(id);
158+
} catch (Resources.NotFoundException e) {
159+
missing.add(field.getName());
160+
}
161+
}
162+
163+
assertTrue("Expected to find app-owned layouts to check", checked > 0);
164+
if (!missing.isEmpty()) {
165+
fail(String.format(
166+
"%d of %d app layout(s) do not resolve on this device "
167+
+ "(smallestScreenWidthDp=%d, %d library layouts skipped): %s",
168+
missing.size(), checked,
169+
resources.getConfiguration().smallestScreenWidthDp, skipped, missing));
170+
}
171+
}
172+
173+
private static boolean isThirdPartyLayout(String layoutName) {
174+
for (String prefix : THIRD_PARTY_LAYOUT_PREFIXES) {
175+
if (layoutName.startsWith(prefix)) {
176+
return true;
177+
}
178+
}
179+
return false;
180+
}
181+
182+
// =========================================================================
183+
// Back navigation — one activity per implementation
184+
// =========================================================================
185+
186+
/**
187+
* SecurityActivity extends AppCompatActivity, so its back handling goes through
188+
* AndroidX's OnBackPressedDispatcher.
189+
*/
190+
@Test
191+
public void securityActivityBackReturnsToPasswordScreen() {
192+
assertBackReachesPasswordScreen(SecurityActivity.class);
193+
}
194+
195+
/**
196+
* PanelWebActivity extends the platform Activity, so its back handling goes through
197+
* BackNavigationCompat and the platform OnBackInvokedDispatcher instead. Covering
198+
* one activity from each family is what makes this pair worth running.
199+
*/
200+
@Test
201+
public void panelWebActivityBackReturnsToPasswordScreen() {
202+
assertBackReachesPasswordScreen(PanelWebActivity.class);
203+
}
204+
205+
/**
206+
* Launches {@code activityClass}, presses back, and requires that
207+
* CheckPasswordHtmlActivity comes up.
208+
* <p>
209+
* Asserting on the destination rather than on the launched activity finishing is
210+
* deliberate: the platform's default back behaviour also finishes it, so a test
211+
* that only checked for finishing would pass even with the back handler dead — which
212+
* is exactly the bug this guards.
213+
*/
214+
private void assertBackReachesPasswordScreen(Class<? extends Activity> activityClass) {
215+
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
216+
Instrumentation.ActivityMonitor monitor = instrumentation.addMonitor(
217+
CheckPasswordHtmlActivity.class.getName(), null, false);
218+
try {
219+
try (ActivityScenario<? extends Activity> scenario =
220+
ActivityScenario.launch(activityClass)) {
221+
instrumentation.waitForIdleSync();
222+
instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_BACK);
223+
224+
Activity destination =
225+
instrumentation.waitForMonitorWithTimeout(monitor, BACK_NAVIGATION_TIMEOUT_MS);
226+
assertNotNull(
227+
activityClass.getSimpleName() + ": back must land on "
228+
+ "CheckPasswordHtmlActivity. A null destination means the back "
229+
+ "handler never ran — the failure mode when only onBackPressed "
230+
+ "is overridden while enableOnBackInvokedCallback is true.",
231+
destination
232+
);
233+
destination.finish();
234+
}
235+
} finally {
236+
instrumentation.removeMonitor(monitor);
237+
}
238+
}
239+
240+
// =========================================================================
241+
// Not covered here: the lock screens
242+
// =========================================================================
243+
//
244+
// PinNativeActivity and PasswordNativeActivity are the highest-risk activities for
245+
// the API 36 orientation change — they declare
246+
// configChanges="keyboardHidden|orientation", which does not cover screenSize, so a
247+
// rotation destroys and recreates them, and a lock screen that does not come back is
248+
// an unlocked device.
249+
//
250+
// They cannot be covered by simply launching them: the app has a guard that tears
251+
// down a lock screen which should not be up (CloseActivity is started, and the lock
252+
// activity goes PAUSED -> STOPPED -> DESTROYED within about a second). That is
253+
// correct behaviour, so a test that launches the lock cold fails for a reason that
254+
// has nothing to do with rotation.
255+
//
256+
// Verifying this needs a real lock command from the panel, then a physical rotation.
257+
// Left as a manual check rather than a test that would pass or fail for the wrong
258+
// reason.
259+
260+
private Resources resources() {
261+
Context context = ApplicationProvider.getApplicationContext();
262+
return context.getResources();
263+
}
264+
}

0 commit comments

Comments
 (0)