Skip to content

Commit 04093fd

Browse files
authored
Fix libxposed API 101 conformance (#794)
Brings the framework in line with the API version master vendors, checked throughout with test modules that assert the documented behaviour and log pass/fail, on a Pixel 6 running Android 17. hookClassInitializer never worked: it aborted the process, and once that was fixed the hook still could not fire, because resolving <clinit> through JNI runs the initialiser during the lookup. It finds the method from ART's layout now — the gap the reflected members leave behind — and the hooker runs ahead of the class's own initialiser. Also fixed: the interceptor chain could resurrect an exception it had already suppressed; ExceptionMode.DEFAULT ignored module.prop, so passthrough was unreachable; getInvoker() threw NPE on an unhooked method; Constructor .newInstance was hookable; an unhooked constructor dispatched Method.invoke's id against itself; getArgs() was mutable; a late-injected system server dispatched onSystemServerStarting into an empty module set; android:ui was reported to modules as the system package; edit().clear() did nothing, and preference updates reached every Android user's hooked processes; empty scope requests never called back; openRemoteFile threw RemoteException where FileNotFoundException is documented; getScope() repeated a package once per user; module.prop was not parsed as Properties. In the manager, one malformed module.prop could blank the entire module list. The list now shows which API each module targets, and staticScope is enforced rather than parsed and ignored — in the picker, in the daemon, and by dropping stale rows at startup. Two changes modules will notice: - Invoker.invoke reports the target's exception wrapped in InvocationTargetException on every path, as Method#invoke does. A module catching the raw exception will stop catching it. - A module declaring staticScope loses scope entries outside its scope.list. The <clinit> lookup is measured on one device, one Android version and one architecture. Its assumptions are re-checked at runtime and it declines rather than guesses, so an unfamiliar layout degrades to "no static initializer" rather than misbehaving.
1 parent 73571c7 commit 04093fd

27 files changed

Lines changed: 784 additions & 83 deletions

File tree

app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import org.lsposed.manager.R;
7070
import org.lsposed.manager.databinding.ItemMasterSwitchBinding;
7171
import org.lsposed.manager.databinding.ItemModuleBinding;
72+
import org.lsposed.manager.databinding.ItemScopeFooterBinding;
7273
import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder;
7374
import org.lsposed.manager.ui.fragment.AppListFragment;
7475
import org.lsposed.manager.ui.fragment.CompileDialogFragment;
@@ -126,6 +127,28 @@ public int getItemCount() {
126127
}
127128
};
128129

130+
/**
131+
* staticScope says the module's scope list is the whole of it, so the list above shows only the
132+
* apps it claims and this rules it off. Nothing follows for a module that does not claim one.
133+
*/
134+
public RecyclerView.Adapter<RecyclerView.ViewHolder> footerAdaptor = new RecyclerView.Adapter<>() {
135+
@NonNull
136+
@Override
137+
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
138+
return new RecyclerView.ViewHolder(ItemScopeFooterBinding.inflate(activity.getLayoutInflater(), parent, false).getRoot()) {
139+
};
140+
}
141+
142+
@Override
143+
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
144+
}
145+
146+
@Override
147+
public int getItemCount() {
148+
return module.staticScope ? 1 : 0;
149+
}
150+
};
151+
129152
private final OnMainSwitchChangeListener switchBarOnCheckedChangeListener = new OnMainSwitchChangeListener() {
130153
@Override
131154
public void onSwitchChanged(Switch view, boolean isChecked) {
@@ -531,6 +554,11 @@ public void refresh(boolean force) {
531554
synchronized (tmpRecList) {
532555
tmpRecList.add(application);
533556
}
557+
} else if (module.staticScope && !tmpChkList.contains(application)) {
558+
// The module says its scope list is the whole of it, so nothing outside that
559+
// list is offered. An app already enabled outside it still has to be shown, or
560+
// it would keep the module loaded from a row nobody can see to switch off.
561+
return;
534562
} else if (shouldHideApp(info, application, tmpChkList)) {
535563
return;
536564
}

app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
9393
var concatAdapter = new ConcatAdapter();
9494
concatAdapter.addAdapter(scopeAdapter.switchAdaptor);
9595
concatAdapter.addAdapter(scopeAdapter);
96+
concatAdapter.addAdapter(scopeAdapter.footerAdaptor);
9697
binding.recyclerView.setAdapter(concatAdapter);
9798
binding.recyclerView.setHasFixedSize(true);
9899
binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));

app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import android.text.SpannableStringBuilder;
3535
import android.text.TextUtils;
3636
import android.text.style.ForegroundColorSpan;
37+
import android.text.style.RelativeSizeSpan;
3738
import android.text.style.StyleSpan;
3839
import android.text.style.TypefaceSpan;
3940
import android.util.SparseArray;
@@ -51,6 +52,7 @@
5152
import androidx.annotation.NonNull;
5253
import androidx.annotation.Nullable;
5354
import androidx.appcompat.widget.SearchView;
55+
import androidx.appcompat.widget.TooltipCompat;
5456
import androidx.constraintlayout.widget.ConstraintLayout;
5557
import androidx.coordinatorlayout.widget.CoordinatorLayout;
5658
import androidx.core.view.MenuProvider;
@@ -558,6 +560,7 @@ public void onLoadCleared(@Nullable Drawable placeholder) {
558560
sb = new SpannableStringBuilder();
559561

560562
int installXposedVersion = ConfigManager.getXposedApiVersion();
563+
bindApiBadge(holder.apiBadge, item, installXposedVersion);
561564
String warningText = null;
562565
if (item.minVersion == 0) {
563566
warningText = getString(R.string.no_min_version_specified);
@@ -748,12 +751,84 @@ public boolean isLoaded() {
748751
return isLoaded && moduleUtil.isModulesLoaded();
749752
}
750753

754+
/**
755+
* The API version a module targets, printed under its icon. The word stays in the ordinary
756+
* secondary colour and the version itself carries the colour, so the eye lands on the one
757+
* part that differs between modules. The whole statement goes in the content description
758+
* and the tooltip, so the colour is never the only thing carrying it.
759+
*/
760+
private void bindApiBadge(TextView badge, ModuleUtil.InstalledModule item, int frameworkApi) {
761+
final String label;
762+
final String status;
763+
final String version;
764+
final int color;
765+
766+
if (item.legacy) {
767+
// A legacy module reports the original Xposed API, which is numbered separately
768+
// from the one this framework implements, so it is never compared against it.
769+
version = item.minVersion > 0 ? String.valueOf(item.minVersion) : null;
770+
label = version != null
771+
? getString(R.string.module_api_badge_legacy, item.minVersion)
772+
: getString(R.string.module_api_badge_legacy_unknown);
773+
status = getString(R.string.module_api_status_legacy);
774+
color = android.R.attr.textColorSecondary;
775+
} else if (item.minVersion <= 0 || item.targetVersion <= 0 || frameworkApi <= 0) {
776+
// Both keys are required of a module. Reporting the one it did declare would put a
777+
// version on screen for a module that never stated which API it was built for.
778+
version = null;
779+
label = getString(R.string.module_api_badge_unknown);
780+
status = getString(R.string.module_api_status_unknown);
781+
color = android.R.attr.textColorSecondary;
782+
} else {
783+
version = String.valueOf(item.targetVersion);
784+
label = getString(R.string.module_api_badge, item.targetVersion);
785+
if (item.minVersion > frameworkApi) {
786+
// It declares it needs more than we have, whatever it targets.
787+
status = getString(R.string.module_api_status_unsupported, item.minVersion, frameworkApi);
788+
color = com.google.android.material.R.attr.colorError;
789+
} else if (item.targetVersion == frameworkApi) {
790+
status = getString(R.string.module_api_status_current);
791+
color = androidx.appcompat.R.attr.colorPrimary;
792+
} else {
793+
status = getString(item.targetVersion > frameworkApi
794+
? R.string.module_api_status_newer
795+
: R.string.module_api_status_older,
796+
item.targetVersion, frameworkApi);
797+
color = android.R.attr.textColorSecondary;
798+
}
799+
}
800+
801+
final int resolved = ResourceUtils.resolveColor(requireActivity().getTheme(), color);
802+
final SpannableStringBuilder text = new SpannableStringBuilder(label);
803+
final int at = version == null ? -1 : label.lastIndexOf(version);
804+
if (at > 0) {
805+
// Small caps: the capitals of the word set smaller than the version beside them, so
806+
// the word reads as a label and the version reads as the value.
807+
text.setSpan(new RelativeSizeSpan(0.78f), 0, at, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
808+
text.setSpan(new StyleSpan(Typeface.BOLD), at, label.length(),
809+
Spannable.SPAN_INCLUSIVE_INCLUSIVE);
810+
text.setSpan(new ForegroundColorSpan(resolved), at, label.length(),
811+
Spannable.SPAN_INCLUSIVE_INCLUSIVE);
812+
} else {
813+
text.setSpan(new RelativeSizeSpan(0.78f), 0, label.length(),
814+
Spannable.SPAN_INCLUSIVE_INCLUSIVE);
815+
text.setSpan(new ForegroundColorSpan(resolved), 0, label.length(),
816+
Spannable.SPAN_INCLUSIVE_INCLUSIVE);
817+
}
818+
819+
badge.setText(text);
820+
badge.setContentDescription(status);
821+
TooltipCompat.setTooltipText(badge, status);
822+
badge.setVisibility(View.VISIBLE);
823+
}
824+
751825
static class ViewHolder extends RecyclerView.ViewHolder {
752826
ConstraintLayout root;
753827
ImageView appIcon;
754828
TextView appName;
755829
TextView appDescription;
756830
TextView appVersion;
831+
TextView apiBadge;
757832
TextView hint;
758833
MaterialCheckBox checkBox;
759834

@@ -764,6 +839,7 @@ static class ViewHolder extends RecyclerView.ViewHolder {
764839
appName = binding.appName;
765840
appDescription = binding.description;
766841
appVersion = binding.versionName;
842+
apiBadge = binding.apiBadge;
767843
hint = binding.hint;
768844
checkBox = binding.checkbox;
769845
}

app/src/main/java/org/lsposed/manager/util/ModuleUtil.java

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ public static synchronized ModuleUtil getInstance() {
8686
}
8787

8888
public static int extractIntPart(String str) {
89+
// minApiVersion and targetApiVersion are required of a module, but a third-party module
90+
// omitting one must not take down the module list.
91+
if (str == null) return 0;
8992
int result = 0, length = str.length();
9093
for (int offset = 0; offset < length; offset++) {
9194
char c = str.charAt(offset);
@@ -295,11 +298,20 @@ private InstalledModule(PackageInfo pkg, ZipFile modernModuleApk) {
295298
try (modernModuleApk) {
296299
var propEntry = modernModuleApk.getEntry("META-INF/xposed/module.prop");
297300
if (propEntry != null) {
298-
var prop = new Properties();
299-
prop.load(modernModuleApk.getInputStream(propEntry));
300-
minVersion = extractIntPart(prop.getProperty("minApiVersion"));
301-
targetVersion = extractIntPart(prop.getProperty("targetApiVersion"));
302-
staticScope = TextUtils.equals(prop.getProperty("staticScope"), "true");
301+
try {
302+
var prop = new Properties();
303+
prop.load(modernModuleApk.getInputStream(propEntry));
304+
minVersion = extractIntPart(prop.getProperty("minApiVersion"));
305+
targetVersion = extractIntPart(prop.getProperty("targetApiVersion"));
306+
staticScope = TextUtils.equals(prop.getProperty("staticScope"), "true");
307+
} catch (IllegalArgumentException e) {
308+
// Properties.load rejects a malformed unicode escape, and nothing read
309+
// out of the file before that point can be trusted either, so the
310+
// module reports no version rather than the defaults above.
311+
minVersion = 0;
312+
targetVersion = 0;
313+
Log.e(App.TAG, "Malformed module.prop in " + pkg.packageName, e);
314+
}
303315
}
304316
var scopeEntry = modernModuleApk.getEntry("META-INF/xposed/scope.list");
305317
if (scopeEntry != null) {
@@ -309,8 +321,11 @@ private InstalledModule(PackageInfo pkg, ZipFile modernModuleApk) {
309321
} else {
310322
scopeList = Collections.emptyList();
311323
}
312-
} catch (IOException | OutOfMemoryError e) {
313-
Log.e(App.TAG, "Error while closing modern module APK", e);
324+
} catch (IOException | OutOfMemoryError | IllegalArgumentException e) {
325+
// Properties.load is documented to throw IllegalArgumentException for a
326+
// malformed unicode escape. Uncaught, one such module.prop leaves the user
327+
// with an empty module list instead of one unreadable entry.
328+
Log.e(App.TAG, "Error while reading modern module APK", e);
314329
}
315330
this.minVersion = minVersion;
316331
this.targetVersion = targetVersion;

app/src/main/res/layout/item_module.xml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,32 @@
4040
android:id="@+id/app_icon"
4141
android:layout_width="@dimen/app_icon_size"
4242
android:layout_height="@dimen/app_icon_size"
43-
app:layout_constraintBottom_toBottomOf="parent"
43+
app:layout_constraintBottom_toTopOf="@id/api_badge"
4444
app:layout_constraintStart_toStartOf="parent"
4545
app:layout_constraintTop_toTopOf="parent"
46+
app:layout_constraintVertical_chainStyle="packed"
4647
tools:ignore="ContentDescription"
4748
tools:srcCompat="@tools:sample/backgrounds/scenic" />
4849

50+
<!-- Under the icon, out of the way of the name, the version and the description. -->
51+
<com.google.android.material.textview.MaterialTextView
52+
android:id="@+id/api_badge"
53+
android:layout_width="wrap_content"
54+
android:layout_height="wrap_content"
55+
android:layout_marginTop="4dp"
56+
android:singleLine="true"
57+
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
58+
android:textColor="?android:attr/textColorSecondary"
59+
android:textIsSelectable="false"
60+
android:textSize="11sp"
61+
android:visibility="gone"
62+
app:layout_constraintBottom_toBottomOf="parent"
63+
app:layout_constraintEnd_toEndOf="@id/app_icon"
64+
app:layout_constraintStart_toStartOf="@id/app_icon"
65+
app:layout_constraintTop_toBottomOf="@id/app_icon"
66+
tools:text="api 101"
67+
tools:visibility="visible" />
68+
4969
<com.google.android.material.textview.MaterialTextView
5070
android:id="@+id/app_name"
5171
android:layout_width="wrap_content"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ This file is part of LSPosed.
4+
~
5+
~ LSPosed is free software: you can redistribute it and/or modify
6+
~ it under the terms of the GNU General Public License as published by
7+
~ the Free Software Foundation, either version 3 of the License, or
8+
~ (at your option) any later version.
9+
~
10+
~ LSPosed is distributed in the hope that it will be useful,
11+
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
~ GNU General Public License for more details.
14+
~
15+
~ You should have received a copy of the GNU General Public License
16+
~ along with LSPosed. If not, see <https://www.gnu.org/licenses/>.
17+
~
18+
~ Copyright (C) 2026 LSPosed Contributors
19+
-->
20+
21+
<!-- Closes the list of a module that claims a fixed scope, since nothing else can follow it. -->
22+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
23+
xmlns:app="http://schemas.android.com/apk/res-auto"
24+
android:layout_width="match_parent"
25+
android:layout_height="wrap_content"
26+
android:gravity="center_vertical"
27+
android:orientation="horizontal"
28+
android:paddingHorizontal="24dp"
29+
android:paddingVertical="24dp">
30+
31+
<View
32+
android:layout_width="0dp"
33+
android:layout_height="1dp"
34+
android:layout_weight="1"
35+
android:background="?attr/colorOutlineVariant" />
36+
37+
<com.google.android.material.textview.MaterialTextView
38+
android:id="@+id/scope_note"
39+
android:layout_width="wrap_content"
40+
android:layout_height="wrap_content"
41+
android:layout_marginHorizontal="12dp"
42+
android:text="@string/module_scope_static"
43+
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
44+
android:textColor="?android:attr/textColorSecondary" />
45+
46+
<View
47+
android:layout_width="0dp"
48+
android:layout_height="1dp"
49+
android:layout_weight="1"
50+
android:background="?attr/colorOutlineVariant" />
51+
52+
</LinearLayout>

app/src/main/res/values/strings.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@
105105
<string name="warning_xposed_min_version">This module requires a newer Xposed version (%d) and thus cannot be activated</string>
106106
<string name="warning_target_version_higher">This module is designed for a newer Xposed version (%d) and thus some functionalities may not work</string>
107107
<string name="no_min_version_specified">This module does not specify the Xposed version it needs.</string>
108+
<string name="module_api_badge">API %1$d</string>
109+
<string name="module_api_badge_unknown">API ?</string>
110+
<string name="module_api_badge_legacy">Xposed %1$d</string>
111+
<string name="module_api_badge_legacy_unknown">Xposed ?</string>
112+
<string name="module_api_status_current">Targets the API this framework implements</string>
113+
<string name="module_api_status_older">Targets API %1$d, older than the API this framework implements (%2$d)</string>
114+
<string name="module_api_status_newer">Targets API %1$d, newer than the API this framework implements (%2$d)</string>
115+
<string name="module_api_status_unsupported">Needs API %1$d, more than this framework implements (%2$d)</string>
116+
<string name="module_api_status_legacy">Uses the original Xposed API through the legacy bridge</string>
117+
<string name="module_api_status_unknown">The targeted API version is unknown</string>
118+
<string name="module_scope_static">Static Scope</string>
108119
<string name="warning_min_version_too_low">This module was created for Xposed version %1$d, but due to incompatible changes in version %2$d, it has been disabled</string>
109120
<string name="warning_installed_on_external_storage">This module cannot be loaded because it\'s installed on the SD card, please move it to internal storage</string>
110121
<string name="module_uninstall">Uninstall</string>

daemon/src/main/kotlin/org/matrix/vector/daemon/VectorService.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,8 @@ object VectorService : IDaemonService.Stub() {
396396
}
397397
}
398398
}
399-
.onFailure { runCatching { iCallback.onScopeRequestFailed(it.message) } }
399+
// onScopeRequestFailed declares @NonNull, and Throwable.message is frequently null.
400+
.onFailure { runCatching { iCallback.onScopeRequestFailed(it.message ?: it.toString()) } }
400401

401402
NotificationManager.cancelNotification(
402403
NotificationManager.SCOPE_CHANNEL_ID, packageName, userId)

0 commit comments

Comments
 (0)