Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/scripts/run-instrumented-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/bin/sh
# Runs the instrumented suite inside the emulator-runner's script step.
#
# This lives in a file rather than inline in ci.yml because
# reactivecircus/android-emulator-runner executes each line of an inline script in
# its own shell. Any multi-line construct therefore breaks: an inline `if` fails
# with "Syntax error: end of file unexpected (expecting fi)" because only the first
# line reaches the shell. Invoking one script keeps it to a single line.
#
# Usage: run-instrumented-tests.sh <api-level>
set -e

API_LEVEL="$1"
if [ -z "$API_LEVEL" ]; then
echo "usage: $0 <api-level>" >&2
exit 2
fi

if [ "$API_LEVEL" -ge 36 ]; then
# The runner's AVD is phone-sized, and the API 36 orientation change only
# applies from 600dp of smallest width up. Without this the large-screen tests
# report as skipped and the job goes green having covered nothing.
# 1600dp / (240/160) = 1066dp of smallest width, in landscape.
echo "API $API_LEVEL: forcing a large-screen configuration"
adb shell wm size 2560x1600
adb shell wm density 240
adb shell wm set-ignore-orientation-request true
# Informational, and must not fail the run if the line moves between releases.
adb shell dumpsys window displays | grep -m1 ignoreOrientationRequest || true
else
# Left phone-sized on purpose: this level is here to cover the code that only
# runs below Android 13, where BackNavigationCompat falls back to onBackPressed.
echo "API $API_LEVEL: keeping the default phone configuration"
fi

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

name: CI

Expand Down Expand Up @@ -85,45 +90,53 @@
path: app/build/reports/tests/testDebugUnitTest/

instrumented-test:
name: Instrumented Tests (Emulator)
name: Instrumented Tests (API ${{ matrix.api-level }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
api-level: [30, 36]
steps:
- uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: false

- name: Create CI dummy files
run: |
echo '{"project_info":{"project_number":"0","project_id":"prey-ci","storage_bucket":"prey-ci.appspot.com"},"client":[{"client_info":{"mobilesdk_app_id":"1:0:android:0","android_client_info":{"package_name":"com.prey"}},"api_key":[{"current_key":"fake"}]}],"configuration_version":"1"}' > app/google-services.json
printf 'properties\napi-key-batch=\nemail-batch=\nask-for-name-batch=false\ntoken=\n' > app/src/main/res/raw/batch.properties
printf '<resources>\n <string name="google_maps_key" templateMergeStrategy="preserve" translatable="false">fake-key-for-ci</string>\n</resources>\n' > app/src/main/res/values/google_maps_api.xml
printf 'properties\nprey-campaign=ci\nprey-panel=https://localhost\ngcm-id-prefix=0\nprey-domain=localhost\nprey-subdomain=panel\nemail-feedback=ci@test.com\nsubject-feedback=CI\napi-v2=/api/v2/\nscheduled=false\nminute-scheduled=15\ntimeout-report=60\ngeofence-maximum-accuracy=100\nprey-jwt=fake\nprey-google-play=https://play.google.com\ngeofence-loitering-delay=300000\ndistance-location=50\ngeofence-notification-responsiveness=30000\ndistance-aware=100\nradius-aware=200\nprey-terms=https://localhost/terms\nprey-terms-es=https://localhost/es/terms\nprey-forgot=https://localhost/forgot\nopen-pin=false\n' > app/src/main/res/raw/config.properties

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
api-level: ${{ matrix.api-level }}
arch: x86_64
script: ./gradlew connectedDebugAndroidTest
# One line on purpose: this action runs each line of an inline script in its
# own shell, so a multi-line `if` here fails to parse. The script forces a
# large-screen configuration on API 36 and leaves older levels phone-sized.
script: .github/scripts/run-instrumented-tests.sh ${{ matrix.api-level }}

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

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
6 changes: 3 additions & 3 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,21 @@ task checkstyle(type: Checkstyle) {

android {
namespace 'com.prey'
compileSdk = 35
compileSdk = 36

defaultConfig {
applicationId = "com.prey"
minSdk = 21

targetSdk = 35
targetSdk = 36

versionCode = 417
versionName = '2.6.20'

multiDexEnabled = true

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("int", "COMPILE_SDK_VERSION", "35")
buildConfigField("int", "COMPILE_SDK_VERSION", "36")
buildConfigField("String", "MDM_DEBUG_URL", "\"\"")
applySharedPreyBuildConfig(delegate)
}
Expand Down
5 changes: 5 additions & 0 deletions app/build.gradle.internal
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
apply from: "${projectDir}/build-config-fields.gradle"

// IMPORTANT: this flavor stays on SDK 33 on purpose. The wipe capability we rely
// on is blocked at higher target API levels, so do NOT bump compileSdk/targetSdk
// here when raising them in build.gradle (Play) or build.gradle.dev. This build
// is distributed through an internal process, not Google Play, so it is not
// subject to Play's target API level requirement.
android {
namespace = 'com.prey'
compileSdk = 33
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/*******************************************************************************
* Created by Patricio Jofré
* Copyright 2026 Prey Inc. All rights reserved.
* License: GPLv3
* Full license at "/LICENSE"
******************************************************************************/
package com.prey.activities;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.os.Build;
import android.view.KeyEvent;

import com.prey.R;

import org.junit.Test;
import org.junit.runner.RunWith;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;

import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeTrue;

/**
* On-device checks for the two behaviours Android 16 (API 36) changed for this app.
* <p>
* Run this on a display whose smallest width is at least 600dp — a tablet or foldable
* AVD — because that is where API 36 stops honouring the portrait lock every activity
* here requests. On a phone-sized AVD the orientation assertions still pass but prove
* much less, so {@link #displayIsLargeEnoughToBeMeaningful()} reports the configuration
* the run actually exercised.
* <p>
* These live in androidTest rather than test because they need the real resource
* resolution and the real back dispatch, and because non-exported activities cannot be
* started from the adb shell — instrumentation runs as the app's own UID, so it can.
*/
@RunWith(AndroidJUnit4.class)
public class LargeScreenBehaviorInstrumentedTest {

private static final long BACK_NAVIGATION_TIMEOUT_MS = 8000;

// =========================================================================
// Configuration the run is actually exercising
// =========================================================================

/**
* Asserts that the portrait lock really is being ignored, which is the premise every
* other test here rests on.
* <p>
* PanelWebActivity declares {@code screenOrientation="portrait"} in the manifest, so
* on a large screen running API 36 it should come up in landscape anyway. If it comes
* up portrait, the platform is still honouring the lock and this run is not exercising
* the change at all.
* <p>
* The preconditions are assumptions rather than assertions on purpose: below API 36, on
* a phone-sized display, or with the device in portrait, this reports as skipped instead
* of passed, so a green suite cannot be mistaken for coverage it did not provide. The
* layout sweeps below still run either way — plain landscape is enough to catch the
* welcomebatch class of bug, on any API level.
*/
@Test
public void portraitLockIsIgnoredOnThisDisplay() {
Configuration config = resources().getConfiguration();
int smallestWidthDp = config.smallestScreenWidthDp;

// Below API 36 the platform still honours the lock, so the assertion below would be
// wrong rather than merely uninformative. This matters for CI, which runs the suite
// on an older API level too, to cover BackNavigationCompat's pre-Android 13 path.
assumeTrue(
String.format("API %d is below 36, where the portrait lock is still honoured",
Build.VERSION.SDK_INT),
Build.VERSION.SDK_INT >= 36);
assumeTrue(
String.format("smallestScreenWidthDp=%d is below 600 — run on a tablet AVD to "
+ "exercise the API 36 orientation change", smallestWidthDp),
smallestWidthDp >= 600);
assumeTrue(
"Device is in portrait, so an ignored portrait lock is indistinguishable from "
+ "an honoured one; rotate the AVD to landscape",
config.orientation == Configuration.ORIENTATION_LANDSCAPE);

try (ActivityScenario<PanelWebActivity> scenario =
ActivityScenario.launch(PanelWebActivity.class)) {
scenario.onActivity(activity -> assertEquals(
String.format("PanelWebActivity asks for portrait, but on a %ddp display "
+ "running API 36 that request must be ignored",
smallestWidthDp),
Configuration.ORIENTATION_LANDSCAPE,
activity.getResources().getConfiguration().orientation));
}
}

// =========================================================================
// Resource resolution in the configuration the device is actually in
// =========================================================================

@Test
public void welcomeBatchLayoutResolvesInThisConfiguration() {
assertNotNull(
"welcomebatch must resolve in the current configuration; it used to exist "
+ "only under -port qualifiers, which threw once API 36 let large "
+ "screens ignore WelcomeBatchActivity's portrait lock",
resources().getLayout(R.layout.welcomebatch)
);
}

/**
* Name prefixes of layouts that come from AppCompat, Material and other AndroidX
* libraries. {@code R.layout} holds the merged resources of every dependency, and some
* library layouts are scoped to one configuration on purpose —
* {@code material_clock_period_toggle_land} exists only for landscape, and the library
* only reaches for it there — so sweeping them reports failures that are not ours.
* <p>
* Keep in sync with the copy in {@code LayoutConfigurationCoverageRobolectricTest},
* which documents how the list was verified against the dependency set.
*/
private static final String[] THIRD_PARTY_LAYOUT_PREFIXES = {
"abc_", "m3_", "material_", "mtrl_", "design_", "notification_",
"select_dialog", "support_", "preference", "browser_actions",
"custom_dialog", "expand_button", "image_frame", "test_",
"fingerprint_dialog", "ime_",
};

@Test
public void everyLayoutResolvesInThisConfiguration() {
Resources resources = resources();
List<String> missing = new ArrayList<>();
int checked = 0;
int skipped = 0;

for (Field field : R.layout.class.getFields()) {
int id;
try {
id = field.getInt(null);
} catch (IllegalAccessException e) {
continue;
}
if (isThirdPartyLayout(field.getName())) {
skipped++;
continue;
}
checked++;
try {
resources.getLayout(id);
} catch (Resources.NotFoundException e) {
missing.add(field.getName());
}
}

assertTrue("Expected to find app-owned layouts to check", checked > 0);
if (!missing.isEmpty()) {
fail(String.format(
"%d of %d app layout(s) do not resolve on this device "
+ "(smallestScreenWidthDp=%d, %d library layouts skipped): %s",
missing.size(), checked,
resources.getConfiguration().smallestScreenWidthDp, skipped, missing));
}
}

private static boolean isThirdPartyLayout(String layoutName) {
for (String prefix : THIRD_PARTY_LAYOUT_PREFIXES) {
if (layoutName.startsWith(prefix)) {
return true;
}
}
return false;
}

// =========================================================================
// Back navigation — one activity per implementation
// =========================================================================

/**
* SecurityActivity extends AppCompatActivity, so its back handling goes through
* AndroidX's OnBackPressedDispatcher.
*/
@Test
public void securityActivityBackReturnsToPasswordScreen() {
assertBackReachesPasswordScreen(SecurityActivity.class);
}

/**
* PanelWebActivity extends the platform Activity, so its back handling goes through
* BackNavigationCompat and the platform OnBackInvokedDispatcher instead. Covering
* one activity from each family is what makes this pair worth running.
*/
@Test
public void panelWebActivityBackReturnsToPasswordScreen() {
assertBackReachesPasswordScreen(PanelWebActivity.class);
}

/**
* Launches {@code activityClass}, presses back, and requires that
* CheckPasswordHtmlActivity comes up.
* <p>
* Asserting on the destination rather than on the launched activity finishing is
* deliberate: the platform's default back behaviour also finishes it, so a test
* that only checked for finishing would pass even with the back handler dead — which
* is exactly the bug this guards.
*/
private void assertBackReachesPasswordScreen(Class<? extends Activity> activityClass) {
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
Instrumentation.ActivityMonitor monitor = instrumentation.addMonitor(
CheckPasswordHtmlActivity.class.getName(), null, false);
try {
try (ActivityScenario<? extends Activity> scenario =
ActivityScenario.launch(activityClass)) {
instrumentation.waitForIdleSync();
instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_BACK);

Activity destination =
instrumentation.waitForMonitorWithTimeout(monitor, BACK_NAVIGATION_TIMEOUT_MS);
assertNotNull(
activityClass.getSimpleName() + ": back must land on "
+ "CheckPasswordHtmlActivity. A null destination means the back "
+ "handler never ran — the failure mode when only onBackPressed "
+ "is overridden while enableOnBackInvokedCallback is true.",
destination
);
destination.finish();
}
} finally {
instrumentation.removeMonitor(monitor);
}
}

// =========================================================================
// Not covered here: the lock screens
// =========================================================================
//
// PinNativeActivity and PasswordNativeActivity are the highest-risk activities for
// the API 36 orientation change — they declare
// configChanges="keyboardHidden|orientation", which does not cover screenSize, so a
// rotation destroys and recreates them, and a lock screen that does not come back is
// an unlocked device.
//
// They cannot be covered by simply launching them: the app has a guard that tears
// down a lock screen which should not be up (CloseActivity is started, and the lock
// activity goes PAUSED -> STOPPED -> DESTROYED within about a second). That is
// correct behaviour, so a test that launches the lock cold fails for a reason that
// has nothing to do with rotation.
//
// Verifying this needs a real lock command from the panel, then a physical rotation.
// Left as a manual check rather than a test that would pass or fail for the wrong
// reason.

private Resources resources() {
Context context = ApplicationProvider.getApplicationContext();
return context.getResources();
}
}
Loading
Loading