From e0059114c997f9437ed280500747c1bab23063a9 Mon Sep 17 00:00:00 2001 From: Leaf <1587968048@qq.com> Date: Mon, 13 Jul 2026 21:46:37 +0800 Subject: [PATCH 1/5] Fix AbstractMethodError crash on Android 17 IServiceConnection Android 17 (API 36) replaced the old 3-argument IServiceConnection.connected(ComponentName, IBinder, boolean) with a new 4-argument overload that adds an IBinderSession parameter, and removed the old overload entirely from the framework interface. Vector's IServiceConnection stub and the ManagerService connection implementation only declared/overrode the old 3-argument signature. When system_server dispatched the new 4-argument connected() to the parasitic manager process on Android 17, the Stub lacked the new abstract method and threw AbstractMethodError, killing the manager process. system_server then hit DeadObjectException while dispatching a broadcast to the dead process, triggering a framework restart (SYSTEM_RESTART) every time the manager was opened. Fix: - Add an IBinderSession stub (android.app.IBinderSession extends IInterface) with binderTransactionCompleted(long) and binderTransactionStarting(String):long, matching the Android 17 framework interface. - Declare both connected() overloads in the IServiceConnection stub so it compiles against Android 8.1~17 (the old overload is simply never invoked on Android 17+). - Override the new 4-argument connected() in ManagerService's connection Stub (no-op, same as the existing 3-argument override). --- .../vector/daemon/ipc/ManagerService.kt | 9 +++++ .../main/java/android/app/IBinderSession.java | 35 +++++++++++++++++++ .../java/android/app/IServiceConnection.java | 14 ++++++++ 3 files changed, 58 insertions(+) create mode 100644 hiddenapi/stubs/src/main/java/android/app/IBinderSession.java diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index 412165747..28f0cb50a 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt @@ -54,7 +54,16 @@ object ManagerService : ILSPManagerService.Stub() { IBinder.DeathRecipient { private val connection = object : android.app.IServiceConnection.Stub() { + // Android 8.1 ~ 16 override fun connected(name: ComponentName?, service: IBinder?, dead: Boolean) {} + + // Android 17+ (new signature with IBinderSession) + override fun connected( + name: ComponentName?, + service: IBinder?, + session: android.app.IBinderSession?, + dead: Boolean + ) {} } init { diff --git a/hiddenapi/stubs/src/main/java/android/app/IBinderSession.java b/hiddenapi/stubs/src/main/java/android/app/IBinderSession.java new file mode 100644 index 000000000..1dc3d5777 --- /dev/null +++ b/hiddenapi/stubs/src/main/java/android/app/IBinderSession.java @@ -0,0 +1,35 @@ +package android.app; + +import android.os.IBinder; +import android.os.IInterface; + +/** + * Stub of {@code android.app.IBinderSession} introduced in Android 17 (API 36+). + * + *

This interface is used as a parameter of the new + * {@link IServiceConnection#connected} overload added in Android 17. On older + * Android versions this class does not exist at runtime, but because the stub + * is only referenced from the new {@code connected} overload (which is never + * invoked on older versions), the absence of the runtime class is not a + * problem — the method is never dispatched there. + */ +public interface IBinderSession extends IInterface { + + String DESCRIPTOR = "android.app.IBinderSession"; + + void binderTransactionCompleted(long transactionId); + + long binderTransactionStarting(String name); + + abstract class Stub extends android.os.Binder implements IBinderSession { + + public static IBinderSession asInterface(IBinder obj) { + throw new UnsupportedOperationException(); + } + + @Override + public IBinder asBinder() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java b/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java index a531e1bc1..4bdcf7637 100644 --- a/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java +++ b/hiddenapi/stubs/src/main/java/android/app/IServiceConnection.java @@ -6,8 +6,22 @@ import android.os.IInterface; public interface IServiceConnection extends IInterface { + + /** + * Old signature, used on Android 8.1 ~ 16. Removed from the framework + * interface in Android 17, but kept here so that code compiling against + * this stub can still override it (the method is simply never invoked on + * Android 17+). + */ void connected(ComponentName name, IBinder service, boolean dead); + /** + * New signature introduced in Android 17 (API 36+). On older Android + * versions this overload does not exist at runtime and is never invoked, + * so overriding it is harmless there. + */ + void connected(ComponentName name, IBinder service, IBinderSession session, boolean dead); + abstract class Stub extends Binder implements IServiceConnection { public static IServiceConnection asInterface(IBinder obj) { From 73f7b331f7e8f09433133e9e4460c3649a1ea99c Mon Sep 17 00:00:00 2001 From: Leaf <1587968048@qq.com> Date: Mon, 13 Jul 2026 21:47:04 +0800 Subject: [PATCH 2/5] Fix native version flag quoting for Windows builds The build system passed string macros to the C/C++ compiler wrapped in single quotes, e.g. -DVERSION_NAME='2.0' and -DINJECTED_PACKAGE_NAME='com.android.shell'. On Linux/macOS the shell strips the single quotes and the compiler sees a quoted string literal, but on Windows single quotes are not shell quoting characters, so the compiler receives -DVERSION_NAME='2.0' (a multi-character constant) or -DINJECTED_PACKAGE_NAME='com.android.shell', causing compilation failures: - narrowing conversion from 'int' to 'const char*' - expected unqualified-id / multi-character constant Fix by passing the values as bare tokens (-DVERSION_NAME=2.0, -DINJECTED_PACKAGE_NAME=com.android.shell) and stringizing them at the C++ side via a STRINGIZE macro, which is robust across Windows and Unix toolchains. Also move the stringize helper macros out of a namespace (macros are not namespace members and cannot be invoked with a qualified name). --- build.gradle.kts | 2 +- native/include/common/config.h | 9 ++++++++- zygisk/build.gradle.kts | 4 ++-- zygisk/src/main/cpp/module.cpp | 12 ++++++++---- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0b078cd04..08ecb2f86 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -102,7 +102,7 @@ subprojects { val flags = listOf( "-DVERSION_CODE=${versionCodeProvider.get()}", - "-DVERSION_NAME='\"${versionNameProvider.get()}\"'", + "-DVERSION_NAME=${versionNameProvider.get()}", ) val args = diff --git a/native/include/common/config.h b/native/include/common/config.h index 88beeeb63..97a06461b 100644 --- a/native/include/common/config.h +++ b/native/include/common/config.h @@ -45,7 +45,14 @@ inline constexpr auto kLinkerPath = "/linker"; /// The version code of the library, populated by the build system. const int kVersionCode = VERSION_CODE; +// Stringize the VERSION_NAME token so the build system can pass it as a bare +// token (e.g. -DVERSION_NAME=2.0) without quoting, which is fragile across +// Windows / Unix toolchains. Macros are not namespace members, so these must +// live at file scope. +#define VECTOR_STRINGIZE(x) #x +#define VECTOR_STRINGIZE_TOKEN(x) VECTOR_STRINGIZE(x) + /// The version name of the library, populated by the build system. -const char *const kVersionName = VERSION_NAME; +const char *const kVersionName = VECTOR_STRINGIZE_TOKEN(VERSION_NAME); } // namespace vector::native diff --git a/zygisk/build.gradle.kts b/zygisk/build.gradle.kts index 1757ab170..be214fe55 100644 --- a/zygisk/build.gradle.kts +++ b/zygisk/build.gradle.kts @@ -27,9 +27,9 @@ android { val flags = listOf( - "-DINJECTED_PACKAGE_NAME='\"${injectedPackageName}\"'", + "-DINJECTED_PACKAGE_NAME=${injectedPackageName}", "-DINJECTED_PACKAGE_UID=${injectedPackageUid}", - "-DMANAGER_PACKAGE_NAME='\"${defaultManagerPackageName}\"'", + "-DMANAGER_PACKAGE_NAME=${defaultManagerPackageName}", ) externalNativeBuild { diff --git a/zygisk/src/main/cpp/module.cpp b/zygisk/src/main/cpp/module.cpp index 1692239a0..8c2b010e1 100644 --- a/zygisk/src/main/cpp/module.cpp +++ b/zygisk/src/main/cpp/module.cpp @@ -32,10 +32,14 @@ constexpr int SHARED_RELRO_UID = 1037; // Android uses this to separate users. UID = AppID + UserID * 10000. constexpr int PER_USER_RANGE = 100000; -// Defined via CMake generated marcos +// Defined via CMake generated marcos. +// Package names are passed as bare tokens (no quotes) because quoting is +// fragile across Windows / Unix toolchains; stringize them here instead. +#define VECTOR_STR(x) #x +#define VECTOR_STR_TOKEN(x) VECTOR_STR(x) constexpr uid_t kHostPackageUid = INJECTED_PACKAGE_UID; -const char *const kHostPackageName = INJECTED_PACKAGE_NAME; -const char *const kManagerPackageName = MANAGER_PACKAGE_NAME; +const char *const kHostPackageName = VECTOR_STR_TOKEN(INJECTED_PACKAGE_NAME); +const char *const kManagerPackageName = VECTOR_STR_TOKEN(MANAGER_PACKAGE_NAME); constexpr uid_t GID_INET = 3003; // Android's Internet group ID. enum RuntimeFlags : uint32_t { @@ -260,7 +264,7 @@ void VectorModule::preAppSpecialize(zygisk::AppSpecializeArgs *args) { jint inet_gid = GID_INET; env_->SetIntArrayRegion(new_gids, original_gids_count, 1, &inet_gid); - args->nice_name = env_->NewStringUTF(INJECTED_PACKAGE_NAME); + args->nice_name = env_->NewStringUTF(kHostPackageName); args->gids = new_gids; } } From 2a633ef0943fba24a71e4f0bf00e434b0da5c7d5 Mon Sep 17 00:00:00 2001 From: Zhang Hongyu Date: Wed, 15 Jul 2026 15:08:13 +0800 Subject: [PATCH 3/5] Fix empty module list and blank README pages (#765) Symptoms: the module list came up empty and detail pages showed nothing. There were two independent causes. 1. DNS. With the "doh" setting on, CloudflareDNS resolved every host only through cloudflare-dns.com (bootstrapped on 1.1.1.1) with no fallback. Where Cloudflare is blocked, every lookup fails immediately, so the list, per-module detail and GitHub all fail at once and the app is unusable until DoH is turned off by hand. DoH is now best-effort: on failure it falls back to the system resolver, remembers the failure for the session, and uses a short DoH timeout so the first fallback is quick. This is the fix for the reported empty list. 2. Endpoints. The old mirror chain no longer serves the list: modules.lsposed.org returns 403 for modules.json, modules-blogcdn returns 418, and modules-cloudflare has no DNS record. Only backup.modules.lsposed.org still serves modules.json; per-module detail (module/.json) is served by both backup and modules.lsposed.org. The single mirror list is split accordingly (list -> backup only; detail -> backup then modules.lsposed.org) and the dead mirrors dropped. Alongside those: - Treat non-2xx and empty/blank/invalid responses as load failures rather than silent successes, and restore the loaded state after a failed refresh so the UI never hangs refreshing. Keep cached data when a remote refresh fails. - README: the list payload no longer reliably carries README/releases, so detail is fetched per module on demand. When detail lacks a README, or all detail mirrors are down, fall back to the module's GitHub repo (github.com/Xposed-Modules-Repo/) for the rendered README. The README tab shows a loading state while detail downloads instead of the empty placeholder, and keeps the already-loaded richer module rather than flickering back to the leaner list summary on a repo refresh. - Rendering: GitHub now emits the heading permalink anchor as a sibling after the heading, so markdown.css's old heading-descendant hide rule missed it and the floated link icon leaked into the left gutter; hide .anchor outright. - "Open in browser" stays on modules.lsposed.org, whose per-module web page (unlike the JSON API host) returns HTML and has no .json suffix. - Added logging (tag LSPosedManager, captured by the daemon verbose log) across list and detail loading so future empty-list/empty-detail reports are diagnosable. Endpoint reference for maintainers: backup.modules.lsposed.org modules.json 200, module/.json 200 modules.lsposed.org modules.json 403, module/.json 200, /module/ HTML 200 modules-blogcdn 418 modules-cloudflare no DNS record Each module mirrors a GitHub repo at github.com/Xposed-Modules-Repo/ (README + releases), the ultimate source behind the GitHub fallbacks. --------- Co-authored-by: Qing <44231502+byemaxx@users.noreply.github.com> Co-authored-by: JingMatrix --- app/src/main/assets/webview/markdown.css | 10 +- .../org/lsposed/manager/repo/RepoLoader.java | 266 +++++++++++++----- .../manager/ui/fragment/RepoItemFragment.java | 165 ++++++++++- .../lsposed/manager/util/CloudflareDNS.java | 27 +- 4 files changed, 389 insertions(+), 79 deletions(-) diff --git a/app/src/main/assets/webview/markdown.css b/app/src/main/assets/webview/markdown.css index c2f4b6885..684aa4816 100644 --- a/app/src/main/assets/webview/markdown.css +++ b/app/src/main/assets/webview/markdown.css @@ -146,11 +146,13 @@ input[type="checkbox"] { color: var(--red-600); } +/* GitHub now emits the heading permalink anchor as a sibling AFTER the + heading (not a child), so the old ".../h6 .octicon-link" hide rule no + longer matches it and the floated icon leaks into the left gutter of the + following block. These permalinks are useless in this viewer (no hover, + no address bar), so hide them outright. */ .markdown-body .anchor { - float: left; - padding-right: 4px; - margin-left: -20px; - line-height: 1; + display: none; } .markdown-body .anchor:focus { diff --git a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java index bb4db3271..cd4ccffe2 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -76,11 +76,20 @@ public boolean upgradable(long versionCode, String versionName) { private final Path repoFile = Paths.get(App.getInstance().getFilesDir().getAbsolutePath(), "repo.json"); private final Set listeners = ConcurrentHashMap.newKeySet(); private boolean repoLoaded = false; - private static final String originRepoUrl = "https://modules.lsposed.org/"; - private static final String backupRepoUrl = "https://modules-blogcdn.lsposed.org/"; - - private static final String secondBackupRepoUrl = "https://modules-cloudflare.lsposed.org/"; - private static String repoUrl = originRepoUrl; + // The full module list is only served by the backup host; modules.lsposed.org + // returns 403 for modules.json, and the blogcdn/cloudflare mirrors are dead. + private static final String[] listRepoUrls = new String[]{ + "https://backup.modules.lsposed.org/" + }; + // Per-module detail JSON is served by both the backup host and the public + // site, so the latter acts as a real fallback for module/.json. + private static final String[] detailRepoUrls = new String[]{ + "https://backup.modules.lsposed.org/", + "https://modules.lsposed.org/" + }; + // Each module is mirrored as a GitHub repo under this org; used as a + // last-resort README source when the JSON API omits it or is unreachable. + private static final String moduleGithubReadmeUrl = "https://api.github.com/repos/Xposed-Modules-Repo/%s/readme"; private final Resources resources = App.getInstance().getResources(); private final String[] channels = resources.getStringArray(R.array.update_channel_values); @@ -98,42 +107,77 @@ public static synchronized RepoLoader getInstance() { synchronized public void loadRemoteData() { repoLoaded = false; + boolean loaded = false; + Throwable lastError = null; try { - try (var response = App.getOkHttpClient().newCall(new Request.Builder().url(repoUrl + "modules.json").build()).execute()) { - - if (response.isSuccessful()) { - ResponseBody body = response.body(); - if (body != null) { - try { - String bodyString = body.string(); - Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); - loadLocalData(false); - } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(t)); - for (RepoListener listener : listeners) { - listener.onThrowable(t); - } - } - } + for (String candidateRepoUrl : listRepoUrls) { + try { + String bodyString = requestString(candidateRepoUrl + "modules.json"); + OnlineModule[] repoModules = parseRepoModules(bodyString); + Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); + Log.i(App.TAG, "repo: fetched module list from " + candidateRepoUrl + " (" + repoModules.length + " entries, " + bodyString.length() + " bytes)"); + replaceRepoModules(repoModules); + loaded = true; + break; + } catch (Throwable t) { + lastError = t; + Log.e(App.TAG, "load remote data from " + candidateRepoUrl, t); } } - } catch (Throwable e) { - Log.e(App.TAG, "load remote data", e); - for (RepoListener listener : listeners) { - listener.onThrowable(e); + if (!loaded && lastError != null) { + for (RepoListener listener : listeners) { + listener.onThrowable(lastError); + } } - if (repoUrl.equals(originRepoUrl)) { - repoUrl = backupRepoUrl; - loadRemoteData(); - } else if (repoUrl.equals(backupRepoUrl)) { - repoUrl = secondBackupRepoUrl; - loadRemoteData(); + } finally { + if (!loaded) { + Log.w(App.TAG, "repo: module list load failed on all mirrors, keeping cached data (" + onlineModules.size() + " modules)"); + repoLoaded = true; + for (RepoListener listener : listeners) { + listener.onRepoLoaded(); + } } } } + private OnlineModule[] parseRepoModules(String bodyString) throws IOException { + Gson gson = new Gson(); + OnlineModule[] repoModules = gson.fromJson(bodyString, OnlineModule[].class); + if (repoModules == null) { + throw new IOException("Invalid repo response"); + } + return repoModules; + } + + private void replaceRepoModules(OnlineModule[] repoModules) { + Map modules = new HashMap<>(); + Arrays.stream(repoModules).forEach(onlineModule -> modules.put(onlineModule.getName(), onlineModule)); + var channel = App.getPreferences().getString("update_channel", channels[0]); + onlineModules = modules; + Log.i(App.TAG, "repo: onlineModules replaced, now " + modules.size() + " modules (channel=" + channel + ")"); + updateLatestVersion(repoModules, channel); + } + + private String requestString(String url) throws IOException { + try (var response = App.getOkHttpClient().newCall(new Request.Builder().url(url).build()).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Unexpected response " + response.code() + " from " + response.request().url()); + } + ResponseBody body = response.body(); + if (body == null) { + throw new IOException("Empty response from " + response.request().url()); + } + String bodyString = body.string(); + if (bodyString.trim().isEmpty()) { + throw new IOException("Empty response from " + response.request().url()); + } + return bodyString; + } + } + synchronized public void loadLocalData(boolean updateRemoteRepo) { repoLoaded = false; + Log.i(App.TAG, "repo: loadLocalData(updateRemoteRepo=" + updateRemoteRepo + "), cacheExists=" + Files.exists(repoFile)); try { if (Files.notExists(repoFile)) { loadRemoteData(); @@ -141,15 +185,11 @@ synchronized public void loadLocalData(boolean updateRemoteRepo) { } byte[] encoded = Files.readAllBytes(repoFile); String bodyString = new String(encoded, StandardCharsets.UTF_8); - Gson gson = new Gson(); - Map modules = new HashMap<>(); - OnlineModule[] repoModules = gson.fromJson(bodyString, OnlineModule[].class); - Arrays.stream(repoModules).forEach(onlineModule -> modules.put(onlineModule.getName(), onlineModule)); - var channel = App.getPreferences().getString("update_channel", channels[0]); - updateLatestVersion(repoModules, channel); - onlineModules = modules; + OnlineModule[] repoModules = parseRepoModules(bodyString); + Log.i(App.TAG, "repo: loadLocalData parsed " + repoModules.length + " modules from cache (" + encoded.length + " bytes)"); + replaceRepoModules(repoModules); } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(t)); + Log.e(App.TAG, "repo: loadLocalData failed", t); for (RepoListener listener : listeners) { listener.onThrowable(t); } @@ -248,49 +288,147 @@ else if (module.getLatestBetaReleaseTime() != null) } public void loadRemoteReleases(String packageName) { - App.getOkHttpClient().newCall(new Request.Builder().url(String.format(repoUrl + "module/%s.json", packageName)).build()).enqueue(new Callback() { + loadRemoteReleases(packageName, 0); + } + + private void loadRemoteReleases(String packageName, int attempt) { + if (attempt >= detailRepoUrls.length) { + // Every detail mirror failed; fall back to the module's GitHub repo + // so we can at least recover the README instead of failing outright. + Log.w(App.TAG, "repo: detail mirrors exhausted for " + packageName + ", falling back to GitHub README"); + loadReadmeFromGithub(packageName, null, new IOException("All module detail mirrors failed for " + packageName)); + return; + } + String candidateRepoUrl = detailRepoUrls[attempt]; + Log.i(App.TAG, "repo: loadRemoteReleases " + packageName + " attempt " + attempt + " -> " + candidateRepoUrl); + App.getOkHttpClient().newCall(new Request.Builder().url(String.format(candidateRepoUrl + "module/%s.json", packageName)).build()).enqueue(new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - Log.e(App.TAG, call.request().url() + e.getMessage()); - if (repoUrl.equals(originRepoUrl)) { - repoUrl = backupRepoUrl; - loadRemoteReleases(packageName); - } else if (repoUrl.equals(backupRepoUrl)) { - repoUrl = secondBackupRepoUrl; - loadRemoteReleases(packageName); - } else { + Log.w(App.TAG, "repo: detail fetch failed for " + packageName + " from " + call.request().url() + ": " + e.getMessage()); + loadRemoteReleases(packageName, attempt + 1); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (!response.isSuccessful()) { + Log.w(App.TAG, "repo: detail unexpected response " + response.code() + " for " + packageName + " from " + call.request().url()); + response.close(); + loadRemoteReleases(packageName, attempt + 1); + return; + } + OnlineModule module; + try (response) { + ResponseBody body = response.body(); + if (body == null) { + throw new IOException("Empty response from " + call.request().url()); + } + String bodyString = body.string(); + if (bodyString.trim().isEmpty()) { + throw new IOException("Empty response from " + call.request().url()); + } + Gson gson = new Gson(); + module = gson.fromJson(bodyString, OnlineModule.class); + if (module == null) { + throw new IOException("Invalid response from " + call.request().url()); + } + module.releasesLoaded = true; + onlineModules.replace(packageName, module); + } catch (Throwable t) { + Log.e(App.TAG, "repo: detail parse failed for " + packageName, t); + loadRemoteReleases(packageName, attempt + 1); + return; + } + int releaseCount = module.getReleases() == null ? 0 : module.getReleases().size(); + Log.i(App.TAG, "repo: detail loaded for " + packageName + " from " + candidateRepoUrl + ", hasReadme=" + hasReadme(module) + ", releases=" + releaseCount); + if (hasReadme(module)) { for (RepoListener listener : listeners) { - listener.onThrowable(e); + listener.onModuleReleasesLoaded(module); } + } else { + // Detail loaded but carries no README; enrich it from GitHub + // before publishing so the README tab is not shown as empty. + Log.i(App.TAG, "repo: " + packageName + " detail has no README, fetching from GitHub"); + loadReadmeFromGithub(packageName, module, null); + } + } + }); + } + + private boolean hasReadme(@Nullable OnlineModule module) { + return module != null && ((module.getReadmeHTML() != null && !module.getReadmeHTML().isEmpty()) + || (module.getReadme() != null && !module.getReadme().isEmpty())); + } + + // Fetches the module's rendered README from its GitHub repo. When `loaded` + // is non-null the detail JSON already succeeded (valid releases, README is a + // bonus) and is always published; when null, the JSON mirrors were + // unreachable, so we enrich the cached summary and surface `error` only if + // even the GitHub fetch fails. + private void loadReadmeFromGithub(String packageName, @Nullable OnlineModule loaded, @Nullable Throwable error) { + OnlineModule target = loaded != null ? loaded : onlineModules.get(packageName); + if (target == null) { + Log.w(App.TAG, "repo: no cached module to enrich for " + packageName + ", giving up"); + if (error != null) { + for (RepoListener listener : listeners) { + listener.onThrowable(error); } } + return; + } + App.getOkHttpClient().newCall(new Request.Builder() + .url(String.format(moduleGithubReadmeUrl, packageName)) + .header("Accept", "application/vnd.github.html+json") + .build()).enqueue(new Callback() { + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + Log.w(App.TAG, "repo: GitHub README fetch failed for " + packageName + ": " + e.getMessage()); + publishReadmeFallback(packageName, target, null, loaded != null, error); + } @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful()) { - ResponseBody body = response.body(); - if (body != null) { - try { + String html = null; + try (response) { + if (response.isSuccessful()) { + ResponseBody body = response.body(); + if (body != null) { String bodyString = body.string(); - Gson gson = new Gson(); - OnlineModule module = gson.fromJson(bodyString, OnlineModule.class); - module.releasesLoaded = true; - onlineModules.replace(packageName, module); - for (RepoListener listener : listeners) { - listener.onModuleReleasesLoaded(module); - } - } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(t)); - for (RepoListener listener : listeners) { - listener.onThrowable(t); + if (!bodyString.trim().isEmpty()) { + html = bodyString; } } + } else { + Log.w(App.TAG, "repo: GitHub README unexpected response " + response.code() + " for " + packageName); } + } catch (Throwable t) { + Log.e(App.TAG, "repo: GitHub README read failed for " + packageName, t); } + Log.i(App.TAG, "repo: GitHub README for " + packageName + " -> " + (html != null ? "recovered (" + html.length() + " bytes)" : "unavailable")); + publishReadmeFallback(packageName, target, html, loaded != null, error); } }); } + private void publishReadmeFallback(String packageName, OnlineModule module, @Nullable String readmeHTML, boolean detailLoaded, @Nullable Throwable error) { + if (readmeHTML != null) { + module.setReadmeHTML(readmeHTML); + onlineModules.replace(packageName, module); + } + if (detailLoaded || readmeHTML != null) { + // The releases are already valid, or we recovered a README: publish + // the (possibly enriched) module to the UI. + Log.i(App.TAG, "repo: publishing " + packageName + " (detailLoaded=" + detailLoaded + ", readmeRecovered=" + (readmeHTML != null) + ")"); + for (RepoListener listener : listeners) { + listener.onModuleReleasesLoaded(module); + } + } else if (error != null) { + Log.w(App.TAG, "repo: nothing to publish for " + packageName + ", reporting failure"); + for (RepoListener listener : listeners) { + listener.onThrowable(error); + } + } + } + public void addListener(RepoListener listener) { listeners.add(listener); } diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java index 3dcab2aa2..4f64c8f48 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java @@ -109,6 +109,8 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.RepoLis OnlineModule module; private ReleaseAdapter releaseAdapter; private InformationAdapter informationAdapter; + private boolean remoteModuleLoadRequested = false; + private boolean releaseLoadRequestedByUser = false; @Nullable @Override @@ -147,6 +149,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c releaseAdapter = new ReleaseAdapter(); informationAdapter = new InformationAdapter(); RepoLoader.getInstance().addListener(this); + loadRemoteModuleIfReadmeMissing(); return binding.getRoot(); } @@ -157,6 +160,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) { String modulePackageName = getArguments() == null ? null : getArguments().getString("modulePackageName"); module = RepoLoader.getInstance().getOnlineModule(modulePackageName); + Log.i(App.TAG, "RepoItem: open " + modulePackageName + " -> module " + (module == null ? "NOT FOUND (repoLoaded=" + RepoLoader.getInstance().isRepoLoaded() + "), navigating back" : "found")); if (module == null) { if (!safeNavigate(R.id.action_repo_item_fragment_to_repo_fragment)) { safeNavigate(R.id.repo_nav); @@ -184,7 +188,7 @@ private void renderGithubMarkdown(WebView view, @Nullable String text) { } else { direction = "ltr"; } - if (text == null) { + if (TextUtils.isEmpty(text)) { text = "

" + App.getInstance().getString(R.string.list_empty) + "
"; } if (ResourceUtils.isNightMode(getResources().getConfiguration())) { @@ -238,6 +242,58 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque } } + @Nullable + private OnlineModule refreshModuleFromRepo() { + if (module == null || module.getName() == null) return module; + var updatedModule = RepoLoader.getInstance().getOnlineModule(module.getName()); + if (updatedModule != null) { + // A repo refresh can replace RepoLoader's entry with the summary + // object from modules.json, which lacks README/release detail that + // was already fetched for this fragment. Keep the richer instance so + // the UI does not flicker back to empty/truncated content. + var currentHasDetail = module.releasesLoaded || hasReadme(module); + var updatedHasDetail = updatedModule.releasesLoaded || hasReadme(updatedModule); + if (!currentHasDetail || updatedHasDetail) { + module = updatedModule; + } + } + return module; + } + + private boolean hasReadme(@Nullable OnlineModule module) { + return module != null && (!TextUtils.isEmpty(module.getReadmeHTML()) || !TextUtils.isEmpty(module.getReadme())); + } + + private void loadRemoteModuleIfReadmeMissing() { + var currentModule = refreshModuleFromRepo(); + if (currentModule == null || currentModule.getName() == null) return; + if (remoteModuleLoadRequested || currentModule.releasesLoaded || hasReadme(currentModule)) return; + + remoteModuleLoadRequested = true; + RepoLoader.getInstance().loadRemoteReleases(currentModule.getName()); + } + + // True while the per-module detail (which carries the README) is still being + // fetched, so the README tab can show a loading state instead of the empty + // placeholder on a slow connection. + private boolean isModuleDetailLoading() { + return remoteModuleLoadRequested; + } + + @Nullable + private String getModuleReadme() { + var currentModule = refreshModuleFromRepo(); + if (currentModule == null) return null; + String readme = currentModule.getReadmeHTML(); + if (TextUtils.isEmpty(readme)) { + readme = currentModule.getReadme(); + } + if (TextUtils.isEmpty(readme)) { + loadRemoteModuleIfReadmeMissing(); + } + return readme; + } + @Override public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { @@ -257,23 +313,38 @@ public boolean onMenuItemSelected(@NonNull MenuItem item) { public void onDestroyView() { super.onDestroyView(); RepoLoader.getInstance().removeListener(this); + remoteModuleLoadRequested = false; binding = null; } + @Override + public void onRepoLoaded() { + refreshModuleFromRepo(); + loadRemoteModuleIfReadmeMissing(); + if (releaseAdapter != null) { + runAsync(releaseAdapter::loadItems); + } + } + @Override public void onModuleReleasesLoaded(OnlineModule module) { + if (this.module == null || module == null || !TextUtils.equals(this.module.getName(), module.getName())) return; this.module = module; + remoteModuleLoadRequested = false; var repoLoader = RepoLoader.getInstance(); if (releaseAdapter != null) { runAsync(releaseAdapter::loadItems); } - if ((repoLoader.getReleases(module.getName()) != null ? repoLoader.getReleases(module.getName()).size() : 1) == 1) { + if (releaseLoadRequestedByUser && (repoLoader.getReleases(module.getName()) != null ? repoLoader.getReleases(module.getName()).size() : 1) == 1) { showHint(R.string.module_release_no_more, true); } + releaseLoadRequestedByUser = false; } @Override public void onThrowable(Throwable t) { + remoteModuleLoadRequested = false; + releaseLoadRequestedByUser = false; if (releaseAdapter != null) { runAsync(releaseAdapter::loadItems); } @@ -416,7 +487,12 @@ public ReleaseAdapter() { public void loadItems() { var channels = resources.getStringArray(R.array.update_channel_values); var channel = App.getPreferences().getString("update_channel", channels[0]); - var releases = RepoLoader.getInstance().getReleases(module.getName()); + // Prefer this fragment's module when its releases were already loaded + // in full; a repo refresh may have replaced RepoLoader's entry with + // the modules.json summary, whose truncated release list would + // shadow the complete data we already fetched. + List releases = module.releasesLoaded ? module.getReleases() : null; + if (releases == null) releases = RepoLoader.getInstance().getReleases(module.getName()); if (releases == null) releases = module.getReleases(); List tmpList; if (channel.equals(channels[0])) { @@ -431,8 +507,9 @@ public void loadItems() { return !(name != null && name.startsWith("snapshot")) && !(name != null && name.startsWith("nightly")); }).collect(Collectors.toList()) : null; } else tmpList = releases; + List newItems = tmpList != null ? tmpList : new ArrayList<>(); runOnUiThread(() -> { - items = tmpList; + items = newItems; notifyDataSetChanged(); }); } @@ -456,6 +533,7 @@ public void onBindViewHolder(@NonNull ReleaseAdapter.ViewHolder holder, int posi if (holder.progress.getVisibility() == View.GONE) { holder.title.setVisibility(View.GONE); holder.progress.show(); + releaseLoadRequestedByUser = true; RepoLoader.getInstance().loadRemoteReleases(module.getName()); } }); @@ -611,8 +689,42 @@ public void onPause() { } } - public static class ReadmeFragment extends BorderFragment { + public static class ReadmeFragment extends BorderFragment implements RepoLoader.RepoListener { ItemRepoReadmeBinding binding; + private String renderedReadme; + private boolean readmeRendered = false; + + private void renderReadme() { + var parent = getParentFragment(); + if (!(parent instanceof RepoItemFragment) || binding == null) return; + + var repoItemFragment = (RepoItemFragment) parent; + // getModuleReadme() also kicks off the per-module fetch when the + // README is missing, so query the loading state afterwards. + var readme = repoItemFragment.getModuleReadme(); + String display; + if (!TextUtils.isEmpty(readme)) { + display = readme; + } else if (repoItemFragment.isModuleDetailLoading()) { + // Detail is still downloading (e.g. slow connection); show a + // loading placeholder rather than the empty state so users are + // not misled into thinking the module has no README. + display = "
" + getString(R.string.loading) + "
"; + } else { + // Detail has loaded and there is genuinely no README; let + // renderGithubMarkdown fall back to the empty placeholder. + display = null; + } + var pkg = repoItemFragment.module == null ? null : repoItemFragment.module.getName(); + Log.i(App.TAG, "RepoItem: render README for " + pkg + " -> " + (!TextUtils.isEmpty(readme) ? "content" : repoItemFragment.isModuleDetailLoading() ? "loading" : "empty")); + // onRepoLoaded fires on every repo load and channel change; skip the + // WebView reload when the rendered content has not actually changed + // to avoid flicker. + if (readmeRendered && TextUtils.equals(renderedReadme, display)) return; + renderedReadme = display; + readmeRendered = true; + repoItemFragment.renderGithubMarkdown(binding.readme, display); + } @Nullable @Override @@ -624,13 +736,52 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c } return null; } - var repoItemFragment = (RepoItemFragment) parent; binding = ItemRepoReadmeBinding.inflate(getLayoutInflater(), container, false); - repoItemFragment.renderGithubMarkdown(binding.readme, repoItemFragment.module.getReadmeHTML()); borderView = binding.scrollView; + RepoLoader.getInstance().addListener(this); + renderReadme(); return binding.getRoot(); } + @Override + public void onRepoLoaded() { + if (binding != null) { + runOnUiThread(this::renderReadme); + } + } + + @Override + public void onModuleReleasesLoaded(OnlineModule module) { + if (binding != null) { + var parent = getParentFragment(); + if (parent instanceof RepoItemFragment) { + var repoItemFragment = (RepoItemFragment) parent; + if (repoItemFragment.module != null && TextUtils.equals(repoItemFragment.module.getName(), module.getName())) { + runOnUiThread(this::renderReadme); + } + } + } + } + + @Override + public void onThrowable(Throwable t) { + // The fetch failed; re-render so the tab leaves the loading state + // (the parent already reset the in-flight flag before this runnable + // executes) instead of spinning forever. + if (binding != null) { + runOnUiThread(this::renderReadme); + } + } + + @Override + public void onDestroyView() { + RepoLoader.getInstance().removeListener(this); + binding = null; + renderedReadme = null; + readmeRendered = false; + super.onDestroyView(); + } + @Override void scrollToTop() { binding.scrollView.fullScroll(ScrollView.FOCUS_UP); diff --git a/app/src/main/java/org/lsposed/manager/util/CloudflareDNS.java b/app/src/main/java/org/lsposed/manager/util/CloudflareDNS.java index e0bf6214b..5ab0dd17e 100644 --- a/app/src/main/java/org/lsposed/manager/util/CloudflareDNS.java +++ b/app/src/main/java/org/lsposed/manager/util/CloudflareDNS.java @@ -1,6 +1,7 @@ package org.lsposed.manager.util; import android.os.Build; +import android.util.Log; import androidx.annotation.NonNull; @@ -10,6 +11,7 @@ import java.net.Proxy; import java.net.ProxySelector; import java.net.UnknownHostException; +import java.time.Duration; import java.util.List; import okhttp3.ConnectionSpec; @@ -25,6 +27,10 @@ public final class CloudflareDNS implements Dns { public boolean DoH = App.getPreferences().getBoolean("doh", false); public boolean noProxy = ProxySelector.getDefault().select(url.uri()).get(0) == Proxy.NO_PROXY; private final Dns cloudflare; + // Set once the DoH resolver proves unreachable (e.g. Cloudflare blocked on + // this network) so we stop paying its timeout on every subsequent lookup and + // use the system resolver for the rest of the session. + private volatile boolean dohUnavailable = false; public CloudflareDNS() { var trustManager = Platform.get().platformTrustManager(); @@ -42,6 +48,11 @@ public CloudflareDNS() { .cache(App.getOkHttpCache()) .sslSocketFactory(new NoSniFactory(), trustManager) .connectionSpecs(List.of(tls)) + // Fail fast when the DoH endpoint is blocked so the + // system-DNS fallback kicks in quickly instead of + // stalling on the default 10s connect timeout. + .connectTimeout(Duration.ofSeconds(3)) + .callTimeout(Duration.ofSeconds(5)) .build()); try { builder.bootstrapDnsHosts(List.of( @@ -57,10 +68,18 @@ public CloudflareDNS() { @NonNull @Override public List lookup(@NonNull String hostname) throws UnknownHostException { - if (DoH && noProxy) { - return cloudflare.lookup(hostname); - } else { - return SYSTEM.lookup(hostname); + if (DoH && noProxy && !dohUnavailable) { + try { + return cloudflare.lookup(hostname); + } catch (UnknownHostException e) { + // The DoH resolver is unreachable on this network (e.g. Cloudflare + // is blocked). Fall back to the system resolver so the app keeps + // working instead of failing every lookup, and skip DoH for the + // rest of the session. + dohUnavailable = true; + Log.w(App.TAG, "DoH resolver unreachable, falling back to system DNS for this session: " + e.getMessage()); + } } + return SYSTEM.lookup(hostname); } } From 77520cb610c2ce404490032f7c65c394938c3678 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Sat, 18 Jul 2026 00:30:05 +0200 Subject: [PATCH 4/5] Fix dex2oat compatibility state constants drifting from AIDL The daemon re-declared the DEX2OAT_* state values as literals that had drifted out of sync with the ILSPManagerService AIDL contract the Manager decodes with: MOUNT_FAILED, SEPOLICY_INCORRECT and CRASHED were effectively swapped. Since the daemon returns `compatibility` verbatim over the binder, a mount failure was surfaced in the UI as "Crashed", a sepolicy error as "Mount failed", etc. Derive the constants from ILSPManagerService instead of re-declaring them so the AIDL is the single source of truth for both ends and they cannot drift again. --- .../org/matrix/vector/daemon/env/Dex2OatServer.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt index b144e62c1..005a87e58 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/env/Dex2OatServer.kt @@ -14,16 +14,17 @@ import java.io.FileInputStream import java.nio.file.Files import java.nio.file.Paths import kotlinx.coroutines.launch +import org.lsposed.lspd.ILSPManagerService import org.matrix.vector.daemon.VectorDaemon private const val TAG = "VectorDex2Oat" -// Compatibility states matching Manager expectations -const val DEX2OAT_OK = 0 -const val DEX2OAT_MOUNT_FAILED = 1 -const val DEX2OAT_SEPOLICY_INCORRECT = 2 -const val DEX2OAT_SELINUX_PERMISSIVE = 3 -const val DEX2OAT_CRASHED = 4 +// Compatibility states mirrored directly from the ILSPManagerService AIDL contract. +val DEX2OAT_OK = ILSPManagerService.DEX2OAT_OK +val DEX2OAT_MOUNT_FAILED = ILSPManagerService.DEX2OAT_MOUNT_FAILED +val DEX2OAT_SEPOLICY_INCORRECT = ILSPManagerService.DEX2OAT_SEPOLICY_INCORRECT +val DEX2OAT_SELINUX_PERMISSIVE = ILSPManagerService.DEX2OAT_SELINUX_PERMISSIVE +val DEX2OAT_CRASHED = ILSPManagerService.DEX2OAT_CRASHED object Dex2OatServer { private const val WRAPPER32 = "bin/dex2oat32" From 9350c7ceaf523489f5dd5c63371c3fd52bd7cb2c Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Tue, 21 Jul 2026 13:53:03 +0800 Subject: [PATCH 5/5] Fix Re-optimize crash on Android 17 by using the ART Service (#787) The manager's "Re-optimize" action cleared an app's profile and recompiled it through two hidden IPackageManager binder calls, clearApplicationProfileData() and performDexOptMode(). Android 17 removes both from framework.jar, so the daemon crashed with NoSuchMethodError. Dexopt and profiles have been owned by the ART Service since Android 14, which exposes the same operations through the package shell (cmd package art clear-app-profiles / cmd package compile). Route through the shell on Android 14+ and keep the binder calls for older releases; when a shell command fails, fall back to the binder call, which is still available on Android 14 through 16. Both steps now live in a single PackageOptimizer.optimize() that clears the profile before the speed-profile recompile, since a stale profile would otherwise constrain what the profile-guided compile rebuilds. The two service methods collapse into one AIDL entry point, optimizePackage(), so the manager triggers the whole operation in a single IPC. --- .../ui/fragment/CompileDialogFragment.java | 3 +- .../vector/daemon/ipc/ManagerService.kt | 8 +- .../vector/daemon/utils/PackageOptimizer.kt | 106 ++++++++++++++++++ .../matrix/vector/daemon/utils/Workarounds.kt | 32 ------ .../org/lsposed/lspd/ILSPManagerService.aidl | 4 +- 5 files changed, 110 insertions(+), 43 deletions(-) create mode 100644 daemon/src/main/kotlin/org/matrix/vector/daemon/utils/PackageOptimizer.kt diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java index 63f7578c8..9f1b11959 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java @@ -83,8 +83,7 @@ private static class CompileTask extends AsyncTask { @Override protected Throwable doInBackground(String... commands) { try { - LSPManagerServiceHolder.getService().clearApplicationProfileData(commands[0]); - if (LSPManagerServiceHolder.getService().performDexOptMode(commands[0])) { + if (LSPManagerServiceHolder.getService().optimizePackage(commands[0])) { return null; } else { return new UnknownError(); diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index 412165747..2d215c8c6 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt @@ -34,6 +34,7 @@ import org.matrix.vector.daemon.data.PreferenceStore import org.matrix.vector.daemon.env.Dex2OatServer import org.matrix.vector.daemon.env.LogcatMonitor import org.matrix.vector.daemon.system.* +import org.matrix.vector.daemon.utils.PackageOptimizer import org.matrix.vector.daemon.utils.applyXspaceWorkaround import org.matrix.vector.daemon.utils.getRealUsers import rikka.parcelablelist.ParcelableListSlice @@ -416,10 +417,6 @@ object ManagerService : ILSPManagerService.Stub() { override fun restartFor(intent: Intent) {} // No-op matching original - override fun clearApplicationProfileData(packageName: String) { - packageManager?.clearApplicationProfileData(packageName) - } - override fun enableStatusNotification() = PreferenceStore.isStatusNotificationEnabled() override fun setEnableStatusNotification(enable: Boolean) { @@ -433,8 +430,7 @@ object ManagerService : ILSPManagerService.Stub() { } } - override fun performDexOptMode(packageName: String) = - org.matrix.vector.daemon.utils.performDexOptMode(packageName) + override fun optimizePackage(packageName: String) = PackageOptimizer.optimize(packageName) override fun getDex2OatWrapperCompatibility() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) Dex2OatServer.compatibility else 0 diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/PackageOptimizer.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/PackageOptimizer.kt new file mode 100644 index 000000000..0d7d2b904 --- /dev/null +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/PackageOptimizer.kt @@ -0,0 +1,106 @@ +package org.matrix.vector.daemon.utils + +import android.os.Build +import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader +import org.matrix.vector.daemon.system.packageManager + +/** + * Backs the manager's "Re-optimize" action: drops an app's stale ART profiles and rebuilds its + * `speed-profile` dexopt artifacts. + * + * Both steps were historically hidden `IPackageManager` binder calls (`clearApplicationProfileData` + * / `performDexOptMode`). They are present through Android 16 (the newest AOSP source available) but + * dropped from `framework.jar` on Android 17, where the binder calls throw `NoSuchMethodError` (see + * #781). Since Android 14, dexopt and profiles are handled by the ART Service, which exposes the + * same operations through its shell (`cmd package art clear-app-profiles` / `cmd package compile`) + * independently of those binder methods. We therefore use the shell on Android 14+, falling back to + * the binder call, and use the binder path directly on older releases. + */ +object PackageOptimizer { + + private const val TAG = "VectorOptimizer" + + private val backend: Backend = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + FallbackBackend(primary = ShellBackend, fallback = BinderBackend) + else BinderBackend + + /** + * Re-optimizes [packageName]: clears its profiles, then forces a profile-guided recompile. + * + * `speed-profile` only AOT-compiles methods recorded in the app's reference profile, so clearing + * first prevents the recompile from re-baking a profile captured before the module set changed. + */ + fun optimize(packageName: String): Boolean { + // Best-effort clear: on failure the recompile merely reuses an older profile, which still beats + // aborting the whole action. + runCatching { backend.clearProfiles(packageName) } + .onFailure { Log.e(TAG, "Failed to clear profiles for $packageName", it) } + return runCatching { backend.compile(packageName) } + .onFailure { Log.e(TAG, "Failed to optimize $packageName", it) } + .getOrDefault(false) + } + + /** Each operation returns whether it succeeded. */ + private interface Backend { + fun clearProfiles(packageName: String): Boolean + + fun compile(packageName: String): Boolean + } + + /** + * Runs [primary], falling back to [fallback] whenever an operation returns false or throws. + * + * On Android 14+ the primary is the ART Service shell and the fallback is the binder call. The + * binder methods are still present on Android 14/15/16, so the fallback is valid there; on 17 they + * are gone, but the shell succeeds so the fallback is never reached. + */ + private class FallbackBackend(private val primary: Backend, private val fallback: Backend) : + Backend { + override fun clearProfiles(packageName: String): Boolean = + firstSuccess({ primary.clearProfiles(packageName) }, { fallback.clearProfiles(packageName) }) + + override fun compile(packageName: String): Boolean = + firstSuccess({ primary.compile(packageName) }, { fallback.compile(packageName) }) + + private fun firstSuccess(vararg attempts: () -> Boolean): Boolean = + attempts.any { runCatching(it).getOrDefault(false) } + } + + /** Android 14+: dexopt and profiles are owned by the ART Service, reachable through its shell. */ + private object ShellBackend : Backend { + override fun clearProfiles(packageName: String): Boolean = + exec("cmd package art clear-app-profiles $packageName").first == 0 + + override fun compile(packageName: String): Boolean { + val (exitCode, output) = exec("cmd package compile -m speed-profile -f $packageName") + return exitCode == 0 && output.contains("Success") + } + + private fun exec(command: String): Pair { + val process = Runtime.getRuntime().exec(command) + val output = BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() } + return process.waitFor() to output + } + } + + /** Pre-14, and the Android 14+ fallback: the hidden `IPackageManager` binder calls. */ + private object BinderBackend : Backend { + override fun clearProfiles(packageName: String): Boolean { + val pm = packageManager ?: return false + pm.clearApplicationProfileData(packageName) + return true + } + + override fun compile(packageName: String): Boolean = + packageManager?.performDexOptMode( + packageName, + false, // useJitProfiles + "speed-profile", + true, + true, + null) == true + } +} diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt index 5cf69c960..fcaa9afa0 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/utils/Workarounds.kt @@ -9,8 +9,6 @@ import android.content.pm.UserInfo import android.os.Build import android.os.IUserManager import android.util.Log -import java.io.BufferedReader -import java.io.InputStreamReader import java.lang.ClassNotFoundException import org.matrix.vector.daemon.system.* @@ -64,36 +62,6 @@ fun applyNotificationWorkaround() { } } -/** - * UpsideDownCake (Android 14) requires executing a shell command for dexopt, whereas older versions - * use reflection/IPC. - */ -fun performDexOptMode(packageName: String): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return runCatching { - val process = - Runtime.getRuntime().exec("cmd package compile -m speed-profile -f $packageName") - val output = BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() } - val exitCode = process.waitFor() - exitCode == 0 && output.contains("Success") - } - .onFailure { Log.e(TAG, "Failed to exectute dexopt via cmd", it) } - .getOrDefault(false) - } else { - return runCatching { - packageManager?.performDexOptMode( - packageName, - false, // useJitProfiles - "speed-profile", - true, - true, - null) == true - } - .onFailure { Log.e(TAG, "Failed to invoke IPackageManager.performDexOptMode", it) } - .getOrDefault(false) - } -} - fun applyXspaceWorkaround(connection: IServiceConnection) { if (isXiaomi) { val intent = diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl index d23398fcb..cc7202dfd 100644 --- a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl @@ -68,12 +68,10 @@ interface ILSPManagerService { void restartFor(in Intent intent) = 35; - boolean performDexOptMode(String packageName) = 40; + boolean optimizePackage(String packageName) = 40; int getDex2OatWrapperCompatibility() = 44; - void clearApplicationProfileData(in String packageName) = 45; - boolean enableStatusNotification() = 47; void setEnableStatusNotification(boolean enable) = 48;