diff --git a/CHANGELOG.md b/CHANGELOG.md index c52aa28fa..674ca1193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased ### Fixed +- Settings > Privacy no longer crashes on open. Three monitor toggles used + `app:key` values (`permission_change_monitor`, `signing_cert_change_monitor`, + `app_change_auditor`) that are not registered in `AppPref`, so + `SettingsDataStore` threw `IllegalArgumentException: Invalid key` while + inflating the screen. Keys now match the registered `enable_*` names. +- Installed application list now loads on Android 17 (API 37). The hidden + `IPackageManager.getInstalled{Packages,Applications}` return type change made the + compiled `ParceledListSlice` call fail with a linkage error, leaving the app list + empty and crashing the Settings > About device screen (which only caught + `Exception`, not the `Error`). The accessors now catch the linkage error, re-dispatch + reflectively, and normalize the result whether the platform returns a + `ParceledListSlice`, a plain `List`, or `null`. - Settings preference navigation now guards against null fragment view, preventing crash on devices where the view is not yet created or has been destroyed during auth flow or process death restore (reported on Xiaomi Redmi M2006C3MNG, API 29). diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompat.java b/app/src/main/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompat.java index f3b3a479a..b14e11e8d 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompat.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompat.java @@ -50,6 +50,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -174,10 +176,18 @@ private static List getInstalledPackagesInternal(@NonNull IPackageM int flags, @UserIdInt int userId) { try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return pm.getInstalledPackages((long) flags, userId).getList(); + Object result; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = pm.getInstalledPackages((long) flags, userId); + } else { + result = pm.getInstalledPackages(flags, userId); + } + } catch (NoSuchMethodError | AbstractMethodError e) { + Log.w(TAG, "getInstalledPackages return type changed; retrying reflectively", e); + result = invokeListMethodReflectively(pm, "getInstalledPackages", flags, userId); } - return pm.getInstalledPackages(flags, userId).getList(); + return extractList(result); } catch (RemoteException e) { return ExUtils.rethrowFromSystemServer(e); } catch (BadParcelableException e) { @@ -197,10 +207,95 @@ public static List getInstalledApplications(int flags, @UserIdI @WorkerThread public static List getInstalledApplications(@NonNull IPackageManager pm, int flags, @UserIdInt int userId) throws RemoteException { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return pm.getInstalledApplications((long) flags, userId).getList(); + Object result; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = pm.getInstalledApplications((long) flags, userId); + } else { + result = pm.getInstalledApplications(flags, userId); + } + } catch (NoSuchMethodError | AbstractMethodError e) { + // Android 17 (API 37) changed the return type of + // IPackageManager#getInstalledApplications, so the ParceledListSlice descriptor baked + // into our bytecode no longer resolves and the direct call fails with a linkage error. + // Re-dispatch reflectively (method resolution ignores the return type) and adapt + // whatever container the platform now hands back. + Log.w(TAG, "getInstalledApplications return type changed; retrying reflectively", e); + result = invokeListMethodReflectively(pm, "getInstalledApplications", flags, userId); + } + return extractList(result); + } + + /** + * Re-dispatch a {@code getInstalled{Packages,Applications}(flags, userId)} call reflectively. + *

+ * The hidden {@link IPackageManager} overloads historically returned a + * {@link ParceledListSlice}, but a future platform (Android 17 / API 37) may change the return + * type. Reflective method resolution matches on name and parameter types only, so it keeps + * working across such a change; {@link #extractList(Object)} then normalizes the result. + */ + @Nullable + private static Object invokeListMethodReflectively(@NonNull IPackageManager pm, @NonNull String methodName, + int flags, @UserIdInt int userId) throws RemoteException { + try { + Method method; + Object[] args; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + method = IPackageManager.class.getMethod(methodName, long.class, int.class); + args = new Object[]{(long) flags, userId}; + } else { + method = IPackageManager.class.getMethod(methodName, int.class, int.class); + args = new Object[]{flags, userId}; + } + return method.invoke(pm, args); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RemoteException) { + throw (RemoteException) cause; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IllegalStateException("Could not invoke " + methodName + " reflectively", cause); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(methodName + " is unavailable on this platform", e); + } + } + + /** + * Normalize the value returned by {@link IPackageManager}'s list accessors into a {@link List}. + *

+ * Handles the historical {@link ParceledListSlice} wrapper, a plain {@link List} (the shape a + * future platform may switch to), a {@code null} result, and any other wrapper that still + * exposes a {@code getList()} accessor. Never returns {@code null}. + */ + @NonNull + @SuppressWarnings("unchecked") + static List extractList(@Nullable Object sliceOrList) { + if (sliceOrList == null) { + return Collections.emptyList(); + } + if (sliceOrList instanceof ParceledListSlice) { + List list = ((ParceledListSlice) sliceOrList).getList(); + return list != null ? list : Collections.emptyList(); + } + if (sliceOrList instanceof List) { + return (List) sliceOrList; + } + // Forward-compat: a future ParceledListSlice replacement that still exposes getList(). + try { + Method getList = sliceOrList.getClass().getMethod("getList"); + Object list = getList.invoke(sliceOrList); + if (list instanceof List) { + return (List) list; + } + } catch (ReflectiveOperationException e) { + Log.w(TAG, "Could not extract list from " + sliceOrList.getClass().getName(), e); } - return pm.getInstalledApplications(flags, userId).getList(); + return Collections.emptyList(); } @NonNull diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/settings/PrivacyPreferences.java b/app/src/main/java/io/github/muntashirakon/AppManager/settings/PrivacyPreferences.java index 94776a646..36c64611d 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/settings/PrivacyPreferences.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/settings/PrivacyPreferences.java @@ -220,7 +220,7 @@ public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable S }); // Permission change monitor (T9). Toggling ON primes the snapshot store so // the very next package update has a known-good baseline to diff against. - SwitchPreferenceCompat permissionMonitor = requirePreference("permission_change_monitor"); + SwitchPreferenceCompat permissionMonitor = requirePreference("enable_permission_change_monitor"); permissionMonitor.setChecked(Prefs.Privacy.isPermissionChangeMonitorEnabled()); permissionMonitor.setOnPreferenceChangeListener((preference, newValue) -> { boolean enabled = (boolean) newValue; @@ -234,7 +234,7 @@ public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable S return true; }); // Signing-cert change monitor (T9 sibling). Same toggle-then-prime pattern. - SwitchPreferenceCompat signingCertMonitor = requirePreference("signing_cert_change_monitor"); + SwitchPreferenceCompat signingCertMonitor = requirePreference("enable_signing_cert_change_monitor"); signingCertMonitor.setChecked(Prefs.Privacy.isSigningCertChangeMonitorEnabled()); signingCertMonitor.setOnPreferenceChangeListener((preference, newValue) -> { boolean enabled = (boolean) newValue; @@ -253,7 +253,7 @@ public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable S showAppChangeFeed(); return true; }); - SwitchPreferenceCompat appChangeAuditor = requirePreference("app_change_auditor"); + SwitchPreferenceCompat appChangeAuditor = requirePreference("enable_app_change_auditor"); appChangeAuditor.setChecked(Prefs.Privacy.isAppChangeAuditorEnabled()); appChangeAuditor.setOnPreferenceChangeListener((preference, newValue) -> { boolean enabled = (boolean) newValue; diff --git a/app/src/main/res/xml/preferences_privacy.xml b/app/src/main/res/xml/preferences_privacy.xml index c7db994c1..e2e6338fe 100644 --- a/app/src/main/res/xml/preferences_privacy.xml +++ b/app/src/main/res/xml/preferences_privacy.xml @@ -72,13 +72,13 @@ app:iconSpaceReserved="false" /> @@ -90,7 +90,7 @@ app:iconSpaceReserved="false" /> diff --git a/app/src/test/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompatListExtractionTest.java b/app/src/test/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompatListExtractionTest.java new file mode 100644 index 000000000..d3e8538be --- /dev/null +++ b/app/src/test/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompatListExtractionTest.java @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.compat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Verifies that {@link PackageManagerCompat#extractList(Object)} normalizes every shape the hidden + * {@code IPackageManager} list accessors can return. This is the forward-compatibility shim that + * keeps the installed-app list populated (and the About-device settings screen from crashing) when a + * future platform such as Android 17 changes the return type away from {@code ParceledListSlice}. + */ +public class PackageManagerCompatListExtractionTest { + @Test + public void nullResultYieldsEmptyList() { + List result = PackageManagerCompat.extractList(null); + assertTrue(result.isEmpty()); + } + + @Test + public void plainListIsReturnedAsIs() { + List source = Arrays.asList("a", "b", "c"); + List result = PackageManagerCompat.extractList(source); + assertSame(source, result); + } + + @Test + public void wrapperExposingGetListIsUnwrapped() { + List backing = new ArrayList<>(Arrays.asList("x", "y")); + List result = PackageManagerCompat.extractList(new FakeSlice(backing)); + assertEquals(backing, result); + } + + @Test + public void wrapperWithNonListGetListYieldsEmptyList() { + List result = PackageManagerCompat.extractList(new BadSlice()); + assertTrue(result.isEmpty()); + } + + @Test + public void unknownObjectYieldsEmptyList() { + List result = PackageManagerCompat.extractList(new Object()); + assertTrue(result.isEmpty()); + } + + @SuppressWarnings("unused") + private static final class FakeSlice { + private final List list; + + FakeSlice(List list) { + this.list = list; + } + + public List getList() { + return list; + } + } + + @SuppressWarnings("unused") + private static final class BadSlice { + public String getList() { + return "not a list"; + } + } +} diff --git a/app/src/test/java/io/github/muntashirakon/AppManager/compat/android17/Android17BehaviorContractTest.java b/app/src/test/java/io/github/muntashirakon/AppManager/compat/android17/Android17BehaviorContractTest.java index 3215bad8d..1b280fc60 100644 --- a/app/src/test/java/io/github/muntashirakon/AppManager/compat/android17/Android17BehaviorContractTest.java +++ b/app/src/test/java/io/github/muntashirakon/AppManager/compat/android17/Android17BehaviorContractTest.java @@ -275,6 +275,21 @@ public void actionSendWithStreamAlwaysGrantsUriPermission() throws IOException { offenders.isEmpty()); } + @Test + public void packageManagerCompatGuardsInstalledListReturnTypeChange() throws IOException { + Path source = findProjectRoot().resolve( + "app/src/main/java/io/github/muntashirakon/AppManager/compat/PackageManagerCompat.java"); + String contents = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + + assertTrue("getInstalled{Packages,Applications} must tolerate the Android 17 return-type" + + " change by catching the linkage error and retrying reflectively", + contents.contains("catch (NoSuchMethodError | AbstractMethodError e)") + && contents.contains("invokeListMethodReflectively")); + assertTrue("The installed list accessors must normalize their result through extractList()" + + " so a null/List/ParceledListSlice all populate the app list", + contents.contains("extractList(result)")); + } + private static Set setOf(String... values) { Set set = new HashSet<>(); for (String value : values) { diff --git a/app/src/test/java/io/github/muntashirakon/AppManager/settings/PrivacyPreferencesKeyParityTest.java b/app/src/test/java/io/github/muntashirakon/AppManager/settings/PrivacyPreferencesKeyParityTest.java new file mode 100644 index 000000000..2132153ef --- /dev/null +++ b/app/src/test/java/io/github/muntashirakon/AppManager/settings/PrivacyPreferencesKeyParityTest.java @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.settings; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.xml.parsers.DocumentBuilderFactory; + +/** + * Privacy settings wires {@link SettingsDataStore}, which rejects any preference + * {@code app:key} that is not registered in {@link io.github.muntashirakon.AppManager.utils.AppPref}. + * A mismatch crashes the screen on open with {@code IllegalArgumentException: Invalid key}. + */ +public class PrivacyPreferencesKeyParityTest { + private static final String APP_NS = "http://schemas.android.com/apk/res-auto"; + private static final Pattern PREF_ENUM = Pattern.compile( + "PREF_[A-Z0-9_]+_(BOOL|INT|LONG|FLOAT|STR)"); + + @Test + public void privacySwitchKeysAreRegisteredInAppPref() throws Exception { + Set appPrefKeys = loadAppPrefKeys(); + Document privacy = parse(findProjectRoot().resolve("app/src/main/res/xml/preferences_privacy.xml")); + NodeList switches = privacy.getElementsByTagName("SwitchPreferenceCompat"); + for (int i = 0; i < switches.getLength(); ++i) { + Element pref = (Element) switches.item(i); + String key = pref.getAttributeNS(APP_NS, "key"); + if (key.isEmpty()) { + continue; + } + boolean persistent = !"false".equals(pref.getAttributeNS(APP_NS, "persistent")); + if (!persistent) { + // e.g. toggle_internet is bound manually and does not use SettingsDataStore. + continue; + } + assertTrue("Privacy switch key '" + key + "' must exist in AppPref or opening" + + " Settings > Privacy crashes with IllegalArgumentException", + appPrefKeys.contains(key)); + } + } + + private static Set loadAppPrefKeys() throws IOException { + Path appPref = findProjectRoot().resolve( + "app/src/main/java/io/github/muntashirakon/AppManager/utils/AppPref.java"); + String source = new String(Files.readAllBytes(appPref), StandardCharsets.UTF_8); + Matcher matcher = PREF_ENUM.matcher(source); + Set keys = new HashSet<>(); + while (matcher.find()) { + String enumName = matcher.group(); + int typeSep = enumName.lastIndexOf('_'); + keys.add(enumName.substring("PREF_".length(), typeSep).toLowerCase(Locale.ROOT)); + } + return keys; + } + + private static Document parse(Path path) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder().parse(path.toFile()); + } + + private static Path findProjectRoot() throws IOException { + Path cursor = Paths.get("").toAbsolutePath(); + for (int i = 0; i < 8 && cursor != null; i++) { + if (Files.exists(cursor.resolve("settings.gradle")) + && Files.exists(cursor.resolve("app/src/main/AndroidManifest.xml"))) { + return cursor; + } + cursor = cursor.getParent(); + } + fail("Could not locate AppManagerNG project root"); + return cursor; + } +}