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/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/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); } } 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/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" 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..095448534 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 @@ -54,7 +55,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 { @@ -416,10 +426,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 +439,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/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) { 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/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; 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; } }