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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -174,10 +176,18 @@ private static List<PackageInfo> 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) {
Expand All @@ -197,10 +207,95 @@ public static List<ApplicationInfo> getInstalledApplications(int flags, @UserIdI
@WorkerThread
public static List<ApplicationInfo> 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.
* <p>
* 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}.
* <p>
* 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 <T> List<T> extractList(@Nullable Object sliceOrList) {
if (sliceOrList == null) {
return Collections.emptyList();
}
if (sliceOrList instanceof ParceledListSlice) {
List<T> list = ((ParceledListSlice) sliceOrList).getList();
return list != null ? list : Collections.emptyList();
}
if (sliceOrList instanceof List) {
return (List<T>) 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<T>) 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions app/src/main/res/xml/preferences_privacy.xml
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,13 @@
app:iconSpaceReserved="false" />

<SwitchPreferenceCompat
app:key="permission_change_monitor"
app:key="enable_permission_change_monitor"
app:title="@string/pref_permission_change_monitor"
app:summary="@string/pref_permission_change_monitor_msg"
app:iconSpaceReserved="false" />

<SwitchPreferenceCompat
app:key="signing_cert_change_monitor"
app:key="enable_signing_cert_change_monitor"
app:title="@string/pref_signing_cert_change_monitor"
app:summary="@string/pref_signing_cert_change_monitor_msg"
app:iconSpaceReserved="false" />
Expand All @@ -90,7 +90,7 @@
app:iconSpaceReserved="false" />

<SwitchPreferenceCompat
app:key="app_change_auditor"
app:key="enable_app_change_auditor"
app:title="@string/pref_app_change_auditor"
app:summary="@string/pref_app_change_auditor_msg"
app:iconSpaceReserved="false" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> result = PackageManagerCompat.extractList(null);
assertTrue(result.isEmpty());
}

@Test
public void plainListIsReturnedAsIs() {
List<String> source = Arrays.asList("a", "b", "c");
List<String> result = PackageManagerCompat.extractList(source);
assertSame(source, result);
}

@Test
public void wrapperExposingGetListIsUnwrapped() {
List<String> backing = new ArrayList<>(Arrays.asList("x", "y"));
List<String> result = PackageManagerCompat.extractList(new FakeSlice(backing));
assertEquals(backing, result);
}

@Test
public void wrapperWithNonListGetListYieldsEmptyList() {
List<String> result = PackageManagerCompat.extractList(new BadSlice());
assertTrue(result.isEmpty());
}

@Test
public void unknownObjectYieldsEmptyList() {
List<String> result = PackageManagerCompat.extractList(new Object());
assertTrue(result.isEmpty());
}

@SuppressWarnings("unused")
private static final class FakeSlice {
private final List<String> list;

FakeSlice(List<String> list) {
this.list = list;
}

public List<String> getList() {
return list;
}
}

@SuppressWarnings("unused")
private static final class BadSlice {
public String getList() {
return "not a list";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> setOf(String... values) {
Set<String> set = new HashSet<>();
for (String value : values) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String> 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;
}
}