diff --git a/.github/scripts/run-instrumented-tests.sh b/.github/scripts/run-instrumented-tests.sh new file mode 100755 index 000000000..62ef2b51f --- /dev/null +++ b/.github/scripts/run-instrumented-tests.sh @@ -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 +set -e + +API_LEVEL="$1" +if [ -z "$API_LEVEL" ]; then + echo "usage: $0 " >&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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f3a822c8..b1cf59a63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -85,8 +90,12 @@ jobs: 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 @@ -117,13 +126,17 @@ jobs: - 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/ diff --git a/app/build.gradle b/app/build.gradle index f928e12fb..8df4c3d29 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,13 +13,13 @@ 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' @@ -27,7 +27,7 @@ android { 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) } diff --git a/app/build.gradle.internal b/app/build.gradle.internal index 8e196fff1..f7bea66f2 100644 --- a/app/build.gradle.internal +++ b/app/build.gradle.internal @@ -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 diff --git a/app/src/androidTest/java/com/prey/activities/LargeScreenBehaviorInstrumentedTest.java b/app/src/androidTest/java/com/prey/activities/LargeScreenBehaviorInstrumentedTest.java new file mode 100644 index 000000000..dbebfdab6 --- /dev/null +++ b/app/src/androidTest/java/com/prey/activities/LargeScreenBehaviorInstrumentedTest.java @@ -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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 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. + *

+ * 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 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. + *

+ * 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 activityClass) { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + Instrumentation.ActivityMonitor monitor = instrumentation.addMonitor( + CheckPasswordHtmlActivity.class.getName(), null, false); + try { + try (ActivityScenario 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(); + } +} diff --git a/app/src/main/java/com/prey/actions/alert/AlertReceiver.java b/app/src/main/java/com/prey/actions/alert/AlertReceiver.java index da564b439..339e95bd1 100644 --- a/app/src/main/java/com/prey/actions/alert/AlertReceiver.java +++ b/app/src/main/java/com/prey/actions/alert/AlertReceiver.java @@ -26,7 +26,12 @@ public void onReceive(final Context context, Intent intent) { PreyLogger.d("AlertReceiver notificationId:" + notificationId); String popupIntent=PopUpAlertActivity.POPUP_PREY+"_"+notificationId; PreyLogger.d("AlertReceiver popup intent:"+popupIntent); - context.sendBroadcast(new Intent(popupIntent)); + // Scoped to this app: PopUpAlertActivity registers for it as + // RECEIVER_NOT_EXPORTED, and an implicit broadcast would otherwise be + // visible to any app with a matching receiver. + Intent popupBroadcast = new Intent(popupIntent); + popupBroadcast.setPackage(context.getPackageName()); + context.sendBroadcast(popupBroadcast); NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); manager.cancel(notificationId); new Thread() { diff --git a/app/src/main/java/com/prey/actions/picture/PictureUtil.java b/app/src/main/java/com/prey/actions/picture/PictureUtil.java index c50e5111b..2e22bacd6 100644 --- a/app/src/main/java/com/prey/actions/picture/PictureUtil.java +++ b/app/src/main/java/com/prey/actions/picture/PictureUtil.java @@ -119,7 +119,7 @@ public static HttpDataService getPicture(Context ctx) { myKillerBundle.putInt("kill",1); intentCamera.putExtras(myKillerBundle); ctx.startActivity(intentCamera); - ctx.sendBroadcast(new Intent(CheckPasswordHtmlActivity.CLOSE_PREY)); + CheckPasswordHtmlActivity.broadcastClosePrey(ctx); } catch (Exception e) { PreyLogger.e("report error:" + e.getMessage(), e); PreyFirebaseCrashlytics.getInstance(ctx).recordException(e); diff --git a/app/src/main/java/com/prey/activities/BackNavigationCompat.java b/app/src/main/java/com/prey/activities/BackNavigationCompat.java new file mode 100644 index 000000000..ec8f0020e --- /dev/null +++ b/app/src/main/java/com/prey/activities/BackNavigationCompat.java @@ -0,0 +1,64 @@ +/******************************************************************************* + * 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.os.Build; +import android.window.OnBackInvokedDispatcher; + +import androidx.annotation.RequiresApi; + +/** + * Routes the system back action to a handler on every API level the app supports. + *

+ * The manifest sets {@code android:enableOnBackInvokedCallback="true"}, which means + * that from Android 13 on the platform dispatches back through + * {@link OnBackInvokedDispatcher} and stops calling {@code Activity.onBackPressed()}. + * Activities that only overrode {@code onBackPressed} were therefore doing nothing + * on modern devices — including the ones whose empty override existed to block back. + *

+ * Activities built on AndroidX ({@code AppCompatActivity}, {@code FragmentActivity}) + * should use {@code getOnBackPressedDispatcher().addCallback(...)} instead, which + * covers both paths. This helper exists for the activities that still extend the + * platform {@link Activity} directly and have no such dispatcher: they register here + * for Android 13+ and keep their {@code onBackPressed} override for older releases, + * where {@code enableOnBackInvokedCallback} is ignored. + */ +public final class BackNavigationCompat { + + private BackNavigationCompat() { + } + + /** + * Registers {@code onBack} as the back handler on Android 13 and later. On older + * releases this is a no-op and the caller's {@code onBackPressed} override keeps + * handling back. + * + * @param activity the activity whose back action is being handled + * @param onBack what to run when back is invoked; do nothing to block back + */ + public static void register(Activity activity, Runnable onBack) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Api33.register(activity, onBack); + } + } + + /** + * Isolated so the API 33 types are only ever loaded on devices that have them. + */ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private static final class Api33 { + + private Api33() { + } + + static void register(Activity activity, Runnable onBack) { + activity.getOnBackInvokedDispatcher().registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_DEFAULT, onBack::run); + } + } +} diff --git a/app/src/main/java/com/prey/activities/CheckPasswordHtmlActivity.java b/app/src/main/java/com/prey/activities/CheckPasswordHtmlActivity.java index 4714482dc..349ce8989 100644 --- a/app/src/main/java/com/prey/activities/CheckPasswordHtmlActivity.java +++ b/app/src/main/java/com/prey/activities/CheckPasswordHtmlActivity.java @@ -84,6 +84,19 @@ public class CheckPasswordHtmlActivity extends AppCompatActivity { public static String URL_ONB = "file:///android_asset/html/index.html"; public static final String CLOSE_PREY = "close_prey"; + + /** + * Broadcasts {@link #CLOSE_PREY} to this app only. The receivers listening for it + * are registered as {@code RECEIVER_NOT_EXPORTED}, so scoping the intent to our own + * package keeps the send side from being visible to other apps too — an implicit + * broadcast is delivered to any app with a matching receiver. + */ + public static void broadcastClosePrey(Context context) { + Intent intent = new Intent(CLOSE_PREY); + intent.setPackage(context.getPackageName()); + context.sendBroadcast(intent); + } + private final BroadcastReceiver close_prey_receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { @@ -138,16 +151,17 @@ protected void onCreate(Bundle savedInstanceState) { } setContentView(R.layout.webview); PreyLogger.d("CheckPasswordHtmlActivity: onCreate"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(close_prey_receiver, new IntentFilter(CLOSE_PREY), RECEIVER_EXPORTED); - } else { - registerReceiver(close_prey_receiver, new IntentFilter(CLOSE_PREY)); - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(restriction_receiver, new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED), RECEIVER_EXPORTED); - } else { - registerReceiver(restriction_receiver, new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)); - } + // CLOSE_PREY is only ever broadcast from inside the app (PictureUtil, + // WebAppInterface, PreySecureService, PreyBetaActionsRunner), so it must not + // be exported: an exported receiver lets any installed app dismiss this + // password screen. Same-package broadcasts are still delivered. + ContextCompat.registerReceiver(this, close_prey_receiver, new IntentFilter(CLOSE_PREY), + ContextCompat.RECEIVER_NOT_EXPORTED); + // Sent by the system when the EMM changes app restrictions, so this one has + // to stay exported to be delivered. + ContextCompat.registerReceiver(this, restriction_receiver, + new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED), + ContextCompat.RECEIVER_EXPORTED); if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) { StrictMode.VmPolicy.Builder builder = new StrictMode.VmPolicy.Builder(); StrictMode.setVmPolicy(builder.build()); diff --git a/app/src/main/java/com/prey/activities/PanelWebActivity.java b/app/src/main/java/com/prey/activities/PanelWebActivity.java index a602c1e0c..af4ea0462 100644 --- a/app/src/main/java/com/prey/activities/PanelWebActivity.java +++ b/app/src/main/java/com/prey/activities/PanelWebActivity.java @@ -6,6 +6,7 @@ ******************************************************************************/ package com.prey.activities; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.graphics.Bitmap; @@ -27,16 +28,25 @@ public class PanelWebActivity extends Activity { private final Activity activity = this; private WebView myWebView = null; + /** + * Only reached below Android 13; from there on the platform routes back to the + * callback registered in {@code onCreate}. Both paths run {@link #goBack()}. + */ + @SuppressLint("GestureBackNavigation") // Still the only back path on API < 33. public void onBackPressed() { + goBack(); + } + + private void goBack() { Intent intent = null; intent = new Intent(getApplication(), CheckPasswordHtmlActivity.class); startActivity(intent); finish(); - } public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + BackNavigationCompat.register(this, this::goBack); setContentView(R.layout.panelweb); this.setContentView(R.layout.activity_webview); myWebView = (WebView) findViewById(R.id.install_browser); diff --git a/app/src/main/java/com/prey/activities/PermissionInformationActivity.java b/app/src/main/java/com/prey/activities/PermissionInformationActivity.java index 4ea1b4bb9..ddf5e95b6 100644 --- a/app/src/main/java/com/prey/activities/PermissionInformationActivity.java +++ b/app/src/main/java/com/prey/activities/PermissionInformationActivity.java @@ -7,6 +7,7 @@ package com.prey.activities; import android.Manifest; +import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Intent; import android.content.pm.ActivityInfo; @@ -35,10 +36,20 @@ public class PermissionInformationActivity extends PreyActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + // Deliberately swallows back: the user must complete the permission flow + // through the screen's own buttons. Registering a callback that does nothing + // is what keeps that true on Android 13+, where onBackPressed is not called. + BackNavigationCompat.register(this, () -> { + }); requestWindowFeature(Window.FEATURE_NO_TITLE); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } + /** + * Only reached below Android 13, where {@code enableOnBackInvokedCallback} is + * ignored. Empty on purpose — see the callback registered in {@code onCreate}. + */ + @SuppressLint("GestureBackNavigation") // Still the only back path on API < 33. @Override public void onBackPressed() { } diff --git a/app/src/main/java/com/prey/activities/PopUpAlertActivity.java b/app/src/main/java/com/prey/activities/PopUpAlertActivity.java index 563403bd2..c7e20c29f 100644 --- a/app/src/main/java/com/prey/activities/PopUpAlertActivity.java +++ b/app/src/main/java/com/prey/activities/PopUpAlertActivity.java @@ -16,6 +16,8 @@ import android.content.IntentFilter; import android.os.Bundle; +import androidx.core.content.ContextCompat; + import com.prey.PreyConfig; import com.prey.PreyLogger; import com.prey.R; @@ -72,8 +74,17 @@ public void onDismiss(DialogInterface dialog) { }); popup.show(); try { - registerReceiver(close_prey_receiver, new IntentFilter(CheckPasswordHtmlActivity.CLOSE_PREY)); - registerReceiver(popup_prey_receiver, new IntentFilter(POPUP_PREY + "_" + notificationId)); + // Both actions are broadcast from inside the app only (AlertReceiver for + // the popup, PreySecureService and friends for CLOSE_PREY), so they must + // not be exported. Without an explicit flag these registrations throw + // SecurityException from Android 14 on, and the catch below turned that + // into a silent failure: the popup could no longer be closed remotely. + ContextCompat.registerReceiver(this, close_prey_receiver, + new IntentFilter(CheckPasswordHtmlActivity.CLOSE_PREY), + ContextCompat.RECEIVER_NOT_EXPORTED); + ContextCompat.registerReceiver(this, popup_prey_receiver, + new IntentFilter(POPUP_PREY + "_" + notificationId), + ContextCompat.RECEIVER_NOT_EXPORTED); } catch (Exception e) { PreyLogger.d(String.format("Error receiver:%s", e.getMessage())); } diff --git a/app/src/main/java/com/prey/activities/ReportActivity.java b/app/src/main/java/com/prey/activities/ReportActivity.java index 2a586d82c..acdd62c89 100644 --- a/app/src/main/java/com/prey/activities/ReportActivity.java +++ b/app/src/main/java/com/prey/activities/ReportActivity.java @@ -13,6 +13,7 @@ import android.webkit.WebSettings; import android.webkit.WebView; +import androidx.activity.OnBackPressedCallback; import androidx.fragment.app.FragmentActivity; import com.google.android.gms.maps.CameraUpdateFactory; @@ -32,17 +33,29 @@ public class ReportActivity extends FragmentActivity implements OnMapReadyCallback, OnMapsSdkInitializedCallback { private WebView myWebView = null; - public void onBackPressed() { - Intent intent = null; - intent = new Intent(getApplication(), CheckPasswordHtmlActivity.class); - startActivity(intent); - finish(); + /** + * Back returns to the password screen rather than exiting. Registered through + * AndroidX's dispatcher, which covers both the pre-Android 13 path and the + * OnBackInvokedCallback path the manifest opts into with + * {@code enableOnBackInvokedCallback}; overriding {@code onBackPressed} no longer + * runs on Android 13+. + */ + private void registerBackNavigation() { + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + Intent intent = new Intent(getApplication(), CheckPasswordHtmlActivity.class); + startActivity(intent); + finish(); + } + }); } @SuppressLint("SetJavaScriptEnabled") @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + registerBackNavigation(); MapsInitializer.initialize(getApplicationContext(), MapsInitializer.Renderer.LATEST, this); setContentView(R.layout.report); PreyLogger.d("ReportActivity: onCreate"); diff --git a/app/src/main/java/com/prey/activities/SecurityActivity.java b/app/src/main/java/com/prey/activities/SecurityActivity.java index 6702f0fcc..525978e5e 100644 --- a/app/src/main/java/com/prey/activities/SecurityActivity.java +++ b/app/src/main/java/com/prey/activities/SecurityActivity.java @@ -14,6 +14,7 @@ import android.webkit.WebSettings; import android.webkit.WebView; +import androidx.activity.OnBackPressedCallback; import androidx.appcompat.app.AppCompatActivity; import com.prey.PreyLogger; @@ -27,17 +28,29 @@ public class SecurityActivity extends AppCompatActivity { private WebView myWebView = null; - public void onBackPressed() { - Intent intent = null; - intent = new Intent(getApplication(), CheckPasswordHtmlActivity.class); - startActivity(intent); - finish(); + /** + * Back returns to the password screen rather than exiting. Registered through + * AndroidX's dispatcher, which covers both the pre-Android 13 path and the + * OnBackInvokedCallback path the manifest opts into with + * {@code enableOnBackInvokedCallback}; overriding {@code onBackPressed} no longer + * runs on Android 13+. + */ + private void registerBackNavigation() { + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + Intent intent = new Intent(getApplication(), CheckPasswordHtmlActivity.class); + startActivity(intent); + finish(); + } + }); } @SuppressLint("SetJavaScriptEnabled") @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + registerBackNavigation(); try { getSupportActionBar().hide(); }catch (Exception e){ diff --git a/app/src/main/java/com/prey/activities/js/WebAppInterface.java b/app/src/main/java/com/prey/activities/js/WebAppInterface.java index 88243a102..9c193d9a8 100644 --- a/app/src/main/java/com/prey/activities/js/WebAppInterface.java +++ b/app/src/main/java/com/prey/activities/js/WebAppInterface.java @@ -655,7 +655,7 @@ public void run() { } catch (Exception e) { PreyLogger.e("Error sleep:"+e.getMessage(),e); } - ctx.sendBroadcast(new Intent(CheckPasswordHtmlActivity.CLOSE_PREY)); + CheckPasswordHtmlActivity.broadcastClosePrey(ctx); } }.start(); diff --git a/app/src/main/java/com/prey/barcodereader/BarcodeActivity.java b/app/src/main/java/com/prey/barcodereader/BarcodeActivity.java index 6a09bb152..1ce31b6c6 100755 --- a/app/src/main/java/com/prey/barcodereader/BarcodeActivity.java +++ b/app/src/main/java/com/prey/barcodereader/BarcodeActivity.java @@ -6,6 +6,7 @@ ******************************************************************************/ package com.prey.barcodereader; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; @@ -32,6 +33,7 @@ import com.prey.PreyUtils; import com.prey.R; import com.prey.actions.aware.AwareController; +import com.prey.activities.BackNavigationCompat; import com.prey.activities.CheckPasswordHtmlActivity; import com.prey.activities.LoginActivity; import com.prey.activities.PermissionInformationActivity; @@ -50,6 +52,7 @@ public class BarcodeActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + BackNavigationCompat.register(this, this::goBack); requestWindowFeature(Window.FEATURE_NO_TITLE); setContentView(R.layout.activity_barcode); statusMessage = (TextView) findViewById(R.id.status_message); @@ -78,8 +81,17 @@ public void onClick(View v) { }); } + /** + * Only reached below Android 13; from there on the platform routes back to the + * callback registered in {@code onCreate}. Both paths run {@link #goBack()}. + */ + @SuppressLint("GestureBackNavigation") // Still the only back path on API < 33. @Override public void onBackPressed() { + goBack(); + } + + private void goBack() { Intent intent =null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { intent = new Intent(getApplicationContext(), CheckPasswordHtmlActivity.class); diff --git a/app/src/main/java/com/prey/beta/actions/PreyBetaActionsRunner.java b/app/src/main/java/com/prey/beta/actions/PreyBetaActionsRunner.java index 9cfa9301e..b862aa64a 100644 --- a/app/src/main/java/com/prey/beta/actions/PreyBetaActionsRunner.java +++ b/app/src/main/java/com/prey/beta/actions/PreyBetaActionsRunner.java @@ -95,7 +95,7 @@ private static List getInstructions(Context ctx,boolean close) throw List jsonObject = null; try { if(close) { - ctx.sendBroadcast(new Intent(CheckPasswordHtmlActivity.CLOSE_PREY)); + CheckPasswordHtmlActivity.broadcastClosePrey(ctx); } jsonObject = PreyWebServices.getInstance().getActionsJsonToPerform(ctx); } catch (PreyException e) { diff --git a/app/src/main/java/com/prey/services/PreyDisablePowerOptionsService.java b/app/src/main/java/com/prey/services/PreyDisablePowerOptionsService.java index 17bcca4c2..849567338 100644 --- a/app/src/main/java/com/prey/services/PreyDisablePowerOptionsService.java +++ b/app/src/main/java/com/prey/services/PreyDisablePowerOptionsService.java @@ -18,6 +18,8 @@ import android.os.IBinder; import android.os.SystemClock; +import androidx.core.content.ContextCompat; + import com.prey.PreyConfig; import com.prey.PreyLogger; import com.prey.receivers.AlarmDisablePowerReceiver; @@ -42,7 +44,7 @@ public void onStart(Intent intent, int startId) { PreyLogger.d("PreyDisablePowerOptionsService onStart ________disablePowerOptions:" + disablePowerOptions); if (disablePowerOptions) { IntentFilter intentfilter = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); - registerReceiver(mReceiver, intentfilter); + registerCloseSystemDialogsReceiver(intentfilter); } } @@ -66,11 +68,24 @@ public int onStartCommand(Intent intent, int i, int j) { PreyLogger.d("PreyDisablePowerOptionsService onStartCommand disablePowerOptions:" + disablePowerOptions); if (disablePowerOptions) { IntentFilter closeDialog = new IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS); - registerReceiver(mReceiver, closeDialog); + registerCloseSystemDialogsReceiver(closeDialog); } return START_STICKY; } + /** + * ACTION_CLOSE_SYSTEM_DIALOGS is delivered by the system, so the receiver stays + * exported. The flag itself is what matters here: from Android 14 on, + * registerReceiver without one throws SecurityException, which took this + * service down with it every time the disable-power-options feature was on. + *

+ * Note the broadcast has not been sent to apps since Android 12, so this only + * still does anything below that. + */ + private void registerCloseSystemDialogsReceiver(IntentFilter filter) { + ContextCompat.registerReceiver(this, mReceiver, filter, ContextCompat.RECEIVER_EXPORTED); + } + public void onTaskRemoved(Intent rootIntent) { PreyLogger.d("Service stopped by Android, we program in 7 seconds"); boolean disablePowerOptions = PreyConfig.getPreyConfig(getApplicationContext()).isDisablePowerOptions(); diff --git a/app/src/main/res/layout-large-port/device_ready.xml b/app/src/main/res/layout-large-port/device_ready.xml deleted file mode 100644 index bf5cbcc4e..000000000 --- a/app/src/main/res/layout-large-port/device_ready.xml +++ /dev/null @@ -1,255 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-large-port/warning.xml b/app/src/main/res/layout-large-port/warning.xml deleted file mode 100644 index f32fdace7..000000000 --- a/app/src/main/res/layout-large-port/warning.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - -