From a5efce53abb6fe773c01f533979db110593d8ee9 Mon Sep 17 00:00:00 2001 From: Shallow Date: Mon, 22 Jun 2026 10:57:37 +0800 Subject: [PATCH 01/17] Fix module repository loading Use the working backup repository API endpoint for module JSON, surface failed HTTP responses, and load full module details when README content is missing. Keeps the browser action on the public modules.lsposed.org web page because the backup host only serves the JSON API. Builds on the approach from #747 by byemaxx. Co-authored-by: Qing <44231502+byemaxx@users.noreply.github.com> --- .../org/lsposed/manager/repo/RepoLoader.java | 107 +++++++++--------- .../manager/ui/fragment/RepoItemFragment.java | 97 +++++++++++++++- 2 files changed, 148 insertions(+), 56 deletions(-) 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..47798eed1 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,7 @@ 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; + private static final String repoUrl = "https://backup.modules.lsposed.org/"; private final Resources resources = App.getInstance().getResources(); private final String[] channels = resources.getStringArray(R.array.update_channel_values); @@ -98,22 +94,25 @@ public static synchronized RepoLoader getInstance() { synchronized public void loadRemoteData() { repoLoaded = false; + boolean loaded = false; 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); - } - } + 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()); + } + try { + String bodyString = body.string(); + Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); + loadLocalData(false); + loaded = true; + } catch (Throwable t) { + Log.e(App.TAG, Log.getStackTraceString(t)); + for (RepoListener listener : listeners) { + listener.onThrowable(t); } } } @@ -122,12 +121,12 @@ synchronized public void loadRemoteData() { for (RepoListener listener : listeners) { listener.onThrowable(e); } - if (repoUrl.equals(originRepoUrl)) { - repoUrl = backupRepoUrl; - loadRemoteData(); - } else if (repoUrl.equals(backupRepoUrl)) { - repoUrl = secondBackupRepoUrl; - loadRemoteData(); + } finally { + if (!loaded) { + repoLoaded = true; + for (RepoListener listener : listeners) { + listener.onRepoLoaded(); + } } } } @@ -252,40 +251,46 @@ public void loadRemoteReleases(String packageName) { @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 { - for (RepoListener listener : listeners) { - listener.onThrowable(e); - } + for (RepoListener listener : listeners) { + listener.onThrowable(e); } } @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful()) { + if (!response.isSuccessful()) { + var e = new IOException("Unexpected response " + response.code() + " from " + call.request().url()); + for (RepoListener listener : listeners) { + listener.onThrowable(e); + } + response.close(); + return; + } + try (response) { ResponseBody body = response.body(); - if (body != null) { - try { - 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 (body == null) { + throw new IOException("Empty response from " + call.request().url()); + } + try { + 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); } } + } catch (Throwable t) { + Log.e(App.TAG, Log.getStackTraceString(t)); + for (RepoListener listener : listeners) { + listener.onThrowable(t); + } } } }); 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..12e280fa2 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(); } @@ -184,7 +187,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 +241,43 @@ 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) { + 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()); + } + + @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) { @@ -260,16 +300,27 @@ public void onDestroyView() { 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; 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 @@ -456,6 +507,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,9 +663,17 @@ public void onPause() { } } - public static class ReadmeFragment extends BorderFragment { + public static class ReadmeFragment extends BorderFragment implements RepoLoader.RepoListener { ItemRepoReadmeBinding binding; + private void renderReadme() { + var parent = getParentFragment(); + if (!(parent instanceof RepoItemFragment) || binding == null) return; + + var repoItemFragment = (RepoItemFragment) parent; + repoItemFragment.renderGithubMarkdown(binding.readme, repoItemFragment.getModuleReadme()); + } + @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { @@ -624,13 +684,40 @@ 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()); + renderReadme(); borderView = binding.scrollView; + RepoLoader.getInstance().addListener(this); 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 onDestroyView() { + RepoLoader.getInstance().removeListener(this); + binding = null; + super.onDestroyView(); + } + @Override void scrollToTop() { binding.scrollView.fullScroll(ScrollView.FOCUS_UP); From 5f247d9a349c1aea7ba20a1fcc0a2529d73ff03e Mon Sep 17 00:00:00 2001 From: Shallow Date: Mon, 22 Jun 2026 12:58:54 +0800 Subject: [PATCH 02/17] Handle repository response edge cases --- .../main/java/org/lsposed/manager/repo/RepoLoader.java | 9 +++++++++ .../lsposed/manager/ui/fragment/RepoItemFragment.java | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) 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 47798eed1..7530654d6 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -106,6 +106,9 @@ synchronized public void loadRemoteData() { } try { String bodyString = body.string(); + if (bodyString.trim().isEmpty()) { + throw new IOException("Empty response from " + response.request().url()); + } Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); loadLocalData(false); loaded = true; @@ -273,8 +276,14 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } try { String bodyString = body.string(); + if (bodyString.trim().isEmpty()) { + throw new IOException("Empty response from " + call.request().url()); + } Gson gson = new Gson(); OnlineModule 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); for (RepoListener listener : listeners) { 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 12e280fa2..dd54df2d8 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 @@ -302,7 +302,10 @@ public void onDestroyView() { @Override public void onRepoLoaded() { - refreshModuleFromRepo(); + var currentModule = refreshModuleFromRepo(); + if (!hasReadme(currentModule)) { + remoteModuleLoadRequested = false; + } loadRemoteModuleIfReadmeMissing(); if (releaseAdapter != null) { runAsync(releaseAdapter::loadItems); @@ -325,6 +328,8 @@ public void onModuleReleasesLoaded(OnlineModule module) { @Override public void onThrowable(Throwable t) { + remoteModuleLoadRequested = false; + releaseLoadRequestedByUser = false; if (releaseAdapter != null) { runAsync(releaseAdapter::loadItems); } From e00bab547255e5b0fa143a7686fb83ca95de6985 Mon Sep 17 00:00:00 2001 From: Shallow Date: Sun, 28 Jun 2026 13:42:04 +0800 Subject: [PATCH 03/17] Restore repository endpoint fallbacks --- .../org/lsposed/manager/repo/RepoLoader.java | 82 ++++++++++++------- .../manager/ui/fragment/RepoItemFragment.java | 7 +- 2 files changed, 56 insertions(+), 33 deletions(-) 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 7530654d6..292613790 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -76,7 +76,13 @@ 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 repoUrl = "https://backup.modules.lsposed.org/"; + private static final String[] repoUrls = new String[]{ + "https://backup.modules.lsposed.org/", + "https://modules.lsposed.org/", + "https://modules-blogcdn.lsposed.org/", + "https://modules-cloudflare.lsposed.org/" + }; + private static String repoUrl = repoUrls[0]; private final Resources resources = App.getInstance().getResources(); private final String[] channels = resources.getStringArray(R.array.update_channel_values); @@ -95,34 +101,25 @@ 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()) { - 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()); - } + for (String candidateRepoUrl : repoUrls) { try { - String bodyString = body.string(); - if (bodyString.trim().isEmpty()) { - throw new IOException("Empty response from " + response.request().url()); - } + String bodyString = requestString(candidateRepoUrl + "modules.json"); Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); + repoUrl = candidateRepoUrl; loadLocalData(false); loaded = true; + break; } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(t)); - for (RepoListener listener : listeners) { - listener.onThrowable(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); + } } } finally { if (!loaded) { @@ -134,6 +131,23 @@ synchronized public void loadRemoteData() { } } + 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; try { @@ -250,23 +264,24 @@ 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 repoUrlIndex) { + String candidateRepoUrl = repoUrls[repoUrlIndex]; + 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()); - for (RepoListener listener : listeners) { - listener.onThrowable(e); - } + retryRemoteReleases(packageName, repoUrlIndex, e); } @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (!response.isSuccessful()) { var e = new IOException("Unexpected response " + response.code() + " from " + call.request().url()); - for (RepoListener listener : listeners) { - listener.onThrowable(e); - } response.close(); + retryRemoteReleases(packageName, repoUrlIndex, e); return; } try (response) { @@ -286,6 +301,7 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } module.releasesLoaded = true; onlineModules.replace(packageName, module); + repoUrl = candidateRepoUrl; for (RepoListener listener : listeners) { listener.onModuleReleasesLoaded(module); } @@ -305,6 +321,16 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { }); } + private void retryRemoteReleases(String packageName, int repoUrlIndex, Throwable error) { + if (repoUrlIndex + 1 < repoUrls.length) { + loadRemoteReleases(packageName, repoUrlIndex + 1); + } else { + 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 dd54df2d8..a51bfb239 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 @@ -302,10 +302,7 @@ public void onDestroyView() { @Override public void onRepoLoaded() { - var currentModule = refreshModuleFromRepo(); - if (!hasReadme(currentModule)) { - remoteModuleLoadRequested = false; - } + refreshModuleFromRepo(); loadRemoteModuleIfReadmeMissing(); if (releaseAdapter != null) { runAsync(releaseAdapter::loadItems); @@ -690,9 +687,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c return null; } binding = ItemRepoReadmeBinding.inflate(getLayoutInflater(), container, false); - renderReadme(); borderView = binding.scrollView; RepoLoader.getInstance().addListener(this); + renderReadme(); return binding.getRoot(); } From dbb09630ddfe3150411466d32c44aef73c635362 Mon Sep 17 00:00:00 2001 From: Shallow Date: Thu, 2 Jul 2026 01:43:15 +0800 Subject: [PATCH 04/17] Retry module detail mirrors on parse failures --- .../org/lsposed/manager/repo/RepoLoader.java | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) 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 292613790..1440b20f1 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -289,33 +289,24 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { if (body == null) { throw new IOException("Empty response from " + call.request().url()); } - try { - String bodyString = body.string(); - if (bodyString.trim().isEmpty()) { - throw new IOException("Empty response from " + call.request().url()); - } - Gson gson = new Gson(); - OnlineModule 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); - repoUrl = candidateRepoUrl; - for (RepoListener listener : listeners) { - listener.onModuleReleasesLoaded(module); - } - } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(t)); - for (RepoListener listener : listeners) { - listener.onThrowable(t); - } + String bodyString = body.string(); + if (bodyString.trim().isEmpty()) { + throw new IOException("Empty response from " + call.request().url()); } - } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(t)); + Gson gson = new Gson(); + OnlineModule 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); + repoUrl = candidateRepoUrl; for (RepoListener listener : listeners) { - listener.onThrowable(t); + listener.onModuleReleasesLoaded(module); } + } catch (Throwable t) { + Log.e(App.TAG, Log.getStackTraceString(t)); + retryRemoteReleases(packageName, repoUrlIndex, t); } } }); From d08a313709b64d131599d34e52ce7005827f2971 Mon Sep 17 00:00:00 2001 From: Shallow Date: Thu, 2 Jul 2026 11:06:49 +0800 Subject: [PATCH 05/17] Keep repository mirror fallback robust --- .../org/lsposed/manager/repo/RepoLoader.java | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) 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 1440b20f1..dc3f28165 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -106,9 +106,10 @@ synchronized public void loadRemoteData() { for (String candidateRepoUrl : repoUrls) { try { String bodyString = requestString(candidateRepoUrl + "modules.json"); + OnlineModule[] repoModules = parseRepoModules(bodyString); Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); repoUrl = candidateRepoUrl; - loadLocalData(false); + replaceRepoModules(repoModules); loaded = true; break; } catch (Throwable t) { @@ -131,6 +132,23 @@ synchronized public void loadRemoteData() { } } + 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]); + updateLatestVersion(repoModules, channel); + onlineModules = modules; + } + private String requestString(String url) throws IOException { try (var response = App.getOkHttpClient().newCall(new Request.Builder().url(url).build()).execute()) { if (!response.isSuccessful()) { @@ -157,13 +175,8 @@ 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); + replaceRepoModules(repoModules); } catch (Throwable t) { Log.e(App.TAG, Log.getStackTraceString(t)); for (RepoListener listener : listeners) { @@ -264,16 +277,29 @@ else if (module.getLatestBetaReleaseTime() != null) } public void loadRemoteReleases(String packageName) { - loadRemoteReleases(packageName, 0); + loadRemoteReleases(packageName, repoUrlIndex(repoUrl), 0); + } + + private int repoUrlIndex(String url) { + for (int i = 0; i < repoUrls.length; i++) { + if (repoUrls[i].equals(url)) { + return i; + } + } + return 0; + } + + private int nextRepoUrlIndex(int repoUrlIndex) { + return (repoUrlIndex + 1) % repoUrls.length; } - private void loadRemoteReleases(String packageName, int repoUrlIndex) { + private void loadRemoteReleases(String packageName, int repoUrlIndex, int attempts) { String candidateRepoUrl = repoUrls[repoUrlIndex]; 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()); - retryRemoteReleases(packageName, repoUrlIndex, e); + retryRemoteReleases(packageName, repoUrlIndex, attempts, e); } @Override @@ -281,7 +307,7 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { if (!response.isSuccessful()) { var e = new IOException("Unexpected response " + response.code() + " from " + call.request().url()); response.close(); - retryRemoteReleases(packageName, repoUrlIndex, e); + retryRemoteReleases(packageName, repoUrlIndex, attempts, e); return; } try (response) { @@ -306,15 +332,15 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } } catch (Throwable t) { Log.e(App.TAG, Log.getStackTraceString(t)); - retryRemoteReleases(packageName, repoUrlIndex, t); + retryRemoteReleases(packageName, repoUrlIndex, attempts, t); } } }); } - private void retryRemoteReleases(String packageName, int repoUrlIndex, Throwable error) { - if (repoUrlIndex + 1 < repoUrls.length) { - loadRemoteReleases(packageName, repoUrlIndex + 1); + private void retryRemoteReleases(String packageName, int repoUrlIndex, int attempts, Throwable error) { + if (attempts + 1 < repoUrls.length) { + loadRemoteReleases(packageName, nextRepoUrlIndex(repoUrlIndex), attempts + 1); } else { for (RepoListener listener : listeners) { listener.onThrowable(error); From f4ad0161a395f903c5d0c25a522bed3e62e1f690 Mon Sep 17 00:00:00 2001 From: Shallow Date: Thu, 2 Jul 2026 11:31:30 +0800 Subject: [PATCH 06/17] Notify repo listeners after module map replacement --- app/src/main/java/org/lsposed/manager/repo/RepoLoader.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dc3f28165..b2c4efeb2 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -145,8 +145,8 @@ 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]); - updateLatestVersion(repoModules, channel); onlineModules = modules; + updateLatestVersion(repoModules, channel); } private String requestString(String url) throws IOException { From 83553ab56bfb836874c45399c848129ef5a48633 Mon Sep 17 00:00:00 2001 From: Shallow Date: Thu, 2 Jul 2026 11:48:16 +0800 Subject: [PATCH 07/17] Reset readme fetch guard after detail load --- .../java/org/lsposed/manager/ui/fragment/RepoItemFragment.java | 1 + 1 file changed, 1 insertion(+) 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 a51bfb239..819c532b0 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 @@ -313,6 +313,7 @@ public void onRepoLoaded() { 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); From 74f60aeb4d7596b7617d772c6f6825b2c8fd2136 Mon Sep 17 00:00:00 2001 From: Shallow Date: Thu, 2 Jul 2026 12:02:12 +0800 Subject: [PATCH 08/17] Clear readme fetch guard on view teardown --- .../java/org/lsposed/manager/ui/fragment/RepoItemFragment.java | 1 + 1 file changed, 1 insertion(+) 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 819c532b0..e2c3d69e3 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 @@ -297,6 +297,7 @@ public boolean onMenuItemSelected(@NonNull MenuItem item) { public void onDestroyView() { super.onDestroyView(); RepoLoader.getInstance().removeListener(this); + remoteModuleLoadRequested = false; binding = null; } From 145ea213aaa1a68e325226038d19a00bdcf118f5 Mon Sep 17 00:00:00 2001 From: Shallow Date: Sat, 4 Jul 2026 12:25:30 +0800 Subject: [PATCH 09/17] Keep loaded module detail across repo refreshes --- .../lsposed/manager/ui/fragment/RepoItemFragment.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 e2c3d69e3..ff53d539b 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 @@ -246,7 +246,15 @@ private OnlineModule refreshModuleFromRepo() { if (module == null || module.getName() == null) return module; var updatedModule = RepoLoader.getInstance().getOnlineModule(module.getName()); if (updatedModule != null) { - module = updatedModule; + // 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; } From 7db9055b44833be7dcee56afd52e02e0326488a8 Mon Sep 17 00:00:00 2001 From: Shallow Date: Sat, 4 Jul 2026 18:30:02 +0800 Subject: [PATCH 10/17] Prefer loaded module releases over repo summary --- .../org/lsposed/manager/ui/fragment/RepoItemFragment.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 ff53d539b..edfdcf16b 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 @@ -479,7 +479,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])) { From f53337fb3b5c4aeaa32f70d44108954952ce62c3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 10 Jul 2026 07:33:52 +0200 Subject: [PATCH 11/17] Fix module repository loading The module repository loader stopped working against the current LSPosed endpoints. This reworks the loader end to end and hardens the repo detail UI against refresh races. RepoLoader: - Try repository mirrors in order (backup.modules.lsposed.org first, then modules.lsposed.org and the blogcdn/cloudflare mirrors), instead of the old origin/backup/second-backup chain. - Treat non-2xx responses and empty/blank bodies as load failures via a shared requestString() helper, and reject unparseable JSON. - Restore the loaded state and notify listeners after a failed refresh so the UI does not stay stuck refreshing; keep previously cached data when a remote refresh fails. - Parse the payload directly on success instead of re-reading the file, and retry per-module detail across all mirrors on failure/parse error. RepoItemFragment: - Load full per-module JSON when README/release detail is missing, then refresh the README tab once it arrives. - Prefer this fragment's already-detailed module over the leaner modules.json summary after a repo refresh, avoiding README/release flicker back to empty/truncated content. - Only show the "no more releases" hint for user-initiated loads, and clear the fetch guard on view teardown, detail load, and errors. - Skip redundant README WebView reloads when the content is unchanged, and guard the release list against a null items list. - Keep the "Open in browser" action on modules.lsposed.org, whose per-module web page (unlike the JSON API) has no .json suffix. Fixes #715 Follow-up to #747 Co-authored-by: Shallow Co-authored-by: Qing <44231502+byemaxx@users.noreply.github.com> --- .../manager/ui/fragment/RepoItemFragment.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 edfdcf16b..2ef141a62 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 @@ -499,8 +499,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(); }); } @@ -682,13 +683,22 @@ public void onPause() { 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; - repoItemFragment.renderGithubMarkdown(binding.readme, repoItemFragment.getModuleReadme()); + var readme = repoItemFragment.getModuleReadme(); + // onRepoLoaded fires on every repo load and channel change; skip the + // WebView reload when the content has not actually changed to avoid + // flicker. + if (readmeRendered && TextUtils.equals(renderedReadme, readme)) return; + renderedReadme = readme; + readmeRendered = true; + repoItemFragment.renderGithubMarkdown(binding.readme, readme); } @Nullable @@ -732,6 +742,8 @@ public void onModuleReleasesLoaded(OnlineModule module) { public void onDestroyView() { RepoLoader.getInstance().removeListener(this); binding = null; + renderedReadme = null; + readmeRendered = false; super.onDestroyView(); } From 42c19a8177cd23bd1b67955155293e267fd9c77f Mon Sep 17 00:00:00 2001 From: Shallow Date: Tue, 14 Jul 2026 18:05:35 +0800 Subject: [PATCH 12/17] Keep listener failures out of mirror retries --- .../main/java/org/lsposed/manager/repo/RepoLoader.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 b2c4efeb2..313a15b39 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -310,6 +310,7 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { retryRemoteReleases(packageName, repoUrlIndex, attempts, e); return; } + OnlineModule module; try (response) { ResponseBody body = response.body(); if (body == null) { @@ -320,19 +321,20 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { throw new IOException("Empty response from " + call.request().url()); } Gson gson = new Gson(); - OnlineModule module = gson.fromJson(bodyString, OnlineModule.class); + 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); repoUrl = candidateRepoUrl; - for (RepoListener listener : listeners) { - listener.onModuleReleasesLoaded(module); - } } catch (Throwable t) { Log.e(App.TAG, Log.getStackTraceString(t)); retryRemoteReleases(packageName, repoUrlIndex, attempts, t); + return; + } + for (RepoListener listener : listeners) { + listener.onModuleReleasesLoaded(module); } } }); From 881fb30a531350c93c9b43e3117eef3d0738c216 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 15 Jul 2026 08:04:24 +0200 Subject: [PATCH 13/17] Show loading state while README detail downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README tab renders the "Nothing here" empty placeholder whenever the text is empty, so on a slow connection a module whose per-module detail is still downloading looked indistinguishable from one that genuinely has no README, misleading users into thinking there was nothing to wait for. Distinguish three states in the README tab: content available, detail still loading, and genuinely empty. While the per-module fetch is in flight (isModuleDetailLoading), show a "Loading…" placeholder instead of the empty state, and re-render on failure so the tab leaves the loading state instead of spinning forever. --- .../manager/ui/fragment/RepoItemFragment.java | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) 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 2ef141a62..e20e6e866 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 @@ -272,6 +272,13 @@ private void loadRemoteModuleIfReadmeMissing() { 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(); @@ -691,14 +698,29 @@ private void renderReadme() { 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; + } // onRepoLoaded fires on every repo load and channel change; skip the - // WebView reload when the content has not actually changed to avoid - // flicker. - if (readmeRendered && TextUtils.equals(renderedReadme, readme)) return; - renderedReadme = readme; + // 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, readme); + repoItemFragment.renderGithubMarkdown(binding.readme, display); } @Nullable @@ -738,6 +760,16 @@ public void onModuleReleasesLoaded(OnlineModule module) { } } + @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); From 21a35b52b451e3c19577c8701ee2592553ed78b9 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 15 Jul 2026 08:04:24 +0200 Subject: [PATCH 14/17] Optimize repo mirrors and add GitHub README fallback Only backup.modules.lsposed.org still serves the module list; the public site returns 403 for modules.json and the blogcdn/cloudflare mirrors are disabled (418) or have no DNS record. Walking all four on every request wasted retries, and the two are used for different things. Split the single mirror list in two: modules.json is fetched only from the backup host, while per-module detail (module/.json) is served by both the backup host and modules.lsposed.org, so the latter is kept as a real fallback. Drop the dead blogcdn/cloudflare mirrors and the stale current-mirror tracking. Add a GitHub README fallback: every module is mirrored as a repo under github.com/Xposed-Modules-Repo/, so when the detail JSON omits the README, or when all detail mirrors are unreachable, fetch the rendered README HTML from the GitHub API and publish the (enriched) module. This keeps the README available even when the JSON API is down. --- .../org/lsposed/manager/repo/RepoLoader.java | 129 +++++++++++++----- 1 file changed, 97 insertions(+), 32 deletions(-) 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 313a15b39..22c6ecd2e 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -76,13 +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[] repoUrls = new String[]{ + // 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/", - "https://modules-blogcdn.lsposed.org/", - "https://modules-cloudflare.lsposed.org/" + "https://modules.lsposed.org/" }; - private static String repoUrl = repoUrls[0]; + // 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); @@ -103,12 +110,11 @@ synchronized public void loadRemoteData() { boolean loaded = false; Throwable lastError = null; try { - for (String candidateRepoUrl : repoUrls) { + for (String candidateRepoUrl : listRepoUrls) { try { String bodyString = requestString(candidateRepoUrl + "modules.json"); OnlineModule[] repoModules = parseRepoModules(bodyString); Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); - repoUrl = candidateRepoUrl; replaceRepoModules(repoModules); loaded = true; break; @@ -277,37 +283,30 @@ else if (module.getLatestBetaReleaseTime() != null) } public void loadRemoteReleases(String packageName) { - loadRemoteReleases(packageName, repoUrlIndex(repoUrl), 0); + loadRemoteReleases(packageName, 0); } - private int repoUrlIndex(String url) { - for (int i = 0; i < repoUrls.length; i++) { - if (repoUrls[i].equals(url)) { - return i; - } + 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. + loadReadmeFromGithub(packageName, null, new IOException("All module detail mirrors failed for " + packageName)); + return; } - return 0; - } - - private int nextRepoUrlIndex(int repoUrlIndex) { - return (repoUrlIndex + 1) % repoUrls.length; - } - - private void loadRemoteReleases(String packageName, int repoUrlIndex, int attempts) { - String candidateRepoUrl = repoUrls[repoUrlIndex]; + String candidateRepoUrl = detailRepoUrls[attempt]; 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()); - retryRemoteReleases(packageName, repoUrlIndex, attempts, e); + loadRemoteReleases(packageName, attempt + 1); } @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (!response.isSuccessful()) { - var e = new IOException("Unexpected response " + response.code() + " from " + call.request().url()); + Log.e(App.TAG, "Unexpected response " + response.code() + " from " + call.request().url()); response.close(); - retryRemoteReleases(packageName, repoUrlIndex, attempts, e); + loadRemoteReleases(packageName, attempt + 1); return; } OnlineModule module; @@ -327,23 +326,89 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } module.releasesLoaded = true; onlineModules.replace(packageName, module); - repoUrl = candidateRepoUrl; } catch (Throwable t) { Log.e(App.TAG, Log.getStackTraceString(t)); - retryRemoteReleases(packageName, repoUrlIndex, attempts, t); + loadRemoteReleases(packageName, attempt + 1); return; } + if (hasReadme(module)) { + for (RepoListener listener : listeners) { + 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. + 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) { + if (error != null) { for (RepoListener listener : listeners) { - listener.onModuleReleasesLoaded(module); + 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.e(App.TAG, call.request().url() + e.getMessage()); + publishReadmeFallback(packageName, target, null, loaded != null, error); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + String html = null; + try (response) { + if (response.isSuccessful()) { + ResponseBody body = response.body(); + if (body != null) { + String bodyString = body.string(); + if (!bodyString.trim().isEmpty()) { + html = bodyString; + } + } + } else { + Log.e(App.TAG, "Unexpected response " + response.code() + " from " + call.request().url()); + } + } catch (Throwable t) { + Log.e(App.TAG, Log.getStackTraceString(t)); + } + publishReadmeFallback(packageName, target, html, loaded != null, error); + } }); } - private void retryRemoteReleases(String packageName, int repoUrlIndex, int attempts, Throwable error) { - if (attempts + 1 < repoUrls.length) { - loadRemoteReleases(packageName, nextRepoUrlIndex(repoUrlIndex), attempts + 1); - } else { + 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. + for (RepoListener listener : listeners) { + listener.onModuleReleasesLoaded(module); + } + } else if (error != null) { for (RepoListener listener : listeners) { listener.onThrowable(error); } From 64f5526941bb437182ea3a3240f2043e4d10f03a Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 15 Jul 2026 08:40:39 +0200 Subject: [PATCH 15/17] Fall back to system DNS when DoH is unreachable When the "doh" preference is on, CloudflareDNS resolved every hostname exclusively through https://cloudflare-dns.com (bootstrapped on 1.1.1.1), with no fallback. On networks where Cloudflare is blocked, the lookup fails immediately, so every request fails (module list, GitHub, etc.) and the app is unusable until DoH is turned off manually. Make DoH best-effort: try it, and on UnknownHostException fall back to the system resolver instead of failing the lookup. Once it fails, skip DoH for the rest of the session so we don't pay its timeout on every lookup, and give the DoH client a short connect/call timeout so the first failure resolves quickly rather than stalling on the default 10s. --- .../lsposed/manager/util/CloudflareDNS.java | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) 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 9bfc84e931029c2dfa97e16ade5a0ec2e886ae1b Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 15 Jul 2026 08:40:39 +0200 Subject: [PATCH 16/17] Add logging across module list and detail loading Trace the repo-loading flow through the LSPosedManager tag (captured by the daemon's verbose log): which mirror served the module list and how many entries it held, the cache path, per-mirror detail attempts and their outcomes, the GitHub README fallback result, and the README tab's render decision (content / loading / empty). Makes empty-list and empty-detail reports diagnosable from logs alone. --- .../org/lsposed/manager/repo/RepoLoader.java | 28 ++++++++++++++----- .../manager/ui/fragment/RepoItemFragment.java | 3 ++ 2 files changed, 24 insertions(+), 7 deletions(-) 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 22c6ecd2e..cd4ccffe2 100644 --- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java +++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java @@ -115,6 +115,7 @@ synchronized public void loadRemoteData() { 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; @@ -130,6 +131,7 @@ synchronized public void 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(); @@ -152,6 +154,7 @@ private void replaceRepoModules(OnlineModule[] repoModules) { 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); } @@ -174,6 +177,7 @@ private String requestString(String url) throws IOException { 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(); @@ -182,9 +186,10 @@ synchronized public void loadLocalData(boolean updateRemoteRepo) { byte[] encoded = Files.readAllBytes(repoFile); String bodyString = new String(encoded, StandardCharsets.UTF_8); 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); } @@ -290,21 +295,23 @@ 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()); + 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.e(App.TAG, "Unexpected response " + response.code() + " from " + call.request().url()); + Log.w(App.TAG, "repo: detail unexpected response " + response.code() + " for " + packageName + " from " + call.request().url()); response.close(); loadRemoteReleases(packageName, attempt + 1); return; @@ -327,10 +334,12 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { module.releasesLoaded = true; onlineModules.replace(packageName, module); } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(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.onModuleReleasesLoaded(module); @@ -338,6 +347,7 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } 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); } } @@ -357,6 +367,7 @@ private boolean hasReadme(@Nullable OnlineModule module) { 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); @@ -370,7 +381,7 @@ private void loadReadmeFromGithub(String packageName, @Nullable OnlineModule loa .build()).enqueue(new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - Log.e(App.TAG, call.request().url() + e.getMessage()); + Log.w(App.TAG, "repo: GitHub README fetch failed for " + packageName + ": " + e.getMessage()); publishReadmeFallback(packageName, target, null, loaded != null, error); } @@ -387,11 +398,12 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } } } else { - Log.e(App.TAG, "Unexpected response " + response.code() + " from " + call.request().url()); + Log.w(App.TAG, "repo: GitHub README unexpected response " + response.code() + " for " + packageName); } } catch (Throwable t) { - Log.e(App.TAG, Log.getStackTraceString(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); } }); @@ -405,10 +417,12 @@ private void publishReadmeFallback(String packageName, OnlineModule module, @Nul 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); } 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 e20e6e866..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 @@ -160,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); @@ -714,6 +715,8 @@ private void renderReadme() { // 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. From bebeefd4d8ee251d0206cdd931a85cd2c14168f3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 15 Jul 2026 08:57:06 +0200 Subject: [PATCH 17/17] Fix module repository loading 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. --- app/src/main/assets/webview/markdown.css | 10 ++++++---- 1 file changed, 6 insertions(+), 4 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 {