Skip to content
151 changes: 91 additions & 60 deletions app/src/main/java/org/lsposed/manager/repo/RepoLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,13 @@ public boolean upgradable(long versionCode, String versionName) {
private final Path repoFile = Paths.get(App.getInstance().getFilesDir().getAbsolutePath(), "repo.json");
private final Set<RepoListener> 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[] 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);

Expand All @@ -98,37 +100,51 @@ 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 : repoUrls) {
try {
String bodyString = requestString(candidateRepoUrl + "modules.json");
Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8));
repoUrl = candidateRepoUrl;
loadLocalData(false);
loaded = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep trying mirrors after parse failures

When a mirror returns HTTP 200 with a malformed or incompatible modules.json, loadLocalData(false) catches the Gson/stream exception internally, so execution returns here and loaded is set to true. That stops the new fallback loop before trying the next mirrors and leaves the just-written bad response in repo.json, so users whose first mirror has a bad cached/deployed response can lose the cached repo instead of falling back to modules.lsposed.org.

Useful? React with 👍 / 👎.

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) {
repoLoaded = true;
for (RepoListener listener : listeners) {
listener.onRepoLoaded();
}
}
}
}

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;
}
}

Expand Down Expand Up @@ -248,49 +264,64 @@ 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse the known working mirror for detail loads

After the repo index or a previous detail request succeeds on a fallback mirror, repoUrl is updated, but this entrypoint always restarts module-detail loads at repoUrls[0]. In environments where the first mirror is blocked or timing out while a later mirror works, every README/release load, including the new automatic README fetch, waits for that failing mirror before reaching the usable one; start from the current repoUrl instead of always index 0.

Useful? React with 👍 / 👎.

}

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());
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);
}
}
retryRemoteReleases(packageName, repoUrlIndex, 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());
response.close();
retryRemoteReleases(packageName, repoUrlIndex, e);
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());
}
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));
retryRemoteReleases(packageName, repoUrlIndex, t);
}
}
});
}

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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -184,7 +187,7 @@ private void renderGithubMarkdown(WebView view, @Nullable String text) {
} else {
direction = "ltr";
}
if (text == null) {
if (TextUtils.isEmpty(text)) {
text = "<center>" + App.getInstance().getString(R.string.list_empty) + "</center>";
}
if (ResourceUtils.isNightMode(getResources().getConfiguration())) {
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep detailed module data across repo refreshes

When a module detail request has already populated this.module with README/full releases, a later repo refresh can replace RepoLoader's entry with the summary object from modules.json (the code below exists specifically for summaries that lack README data). This assignment then discards the already-loaded detail object, so the README/releases can flicker back to empty/truncated data and require another network request; if that retry fails, the user loses content that was already loaded. Please preserve or merge the detailed instance when it has README data or releasesLoaded instead of blindly overwriting it.

Useful? React with 👍 / 👎.

}
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the readme fetch guard after detail loads

When the automatic detail fetch succeeds, remoteModuleLoadRequested stays true. If a repo-list refresh then replaces module with the summary object that still has no readme (for example the normal startup refresh completing after the detail request), this guard prevents loadRemoteModuleIfReadmeMissing() from fetching the detail JSON again, so the Readme tab is rendered as empty until the user manually loads releases. Clear this flag on successful onModuleReleasesLoaded() so a later summary refresh can rehydrate the readme.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset stale readme fetch guard after view teardown

When the automatic detail fetch is started and the fragment view is destroyed before the OkHttp callback runs, onDestroyView() removes this listener, so neither onThrowable nor onModuleReleasesLoaded clears remoteModuleLoadRequested. Returning to the same back-stack fragment then hits this guard and skips the readme/detail fetch even though no request is in flight, leaving modules with missing cached READMEs stuck on the empty placeholder until the user manually loads releases.

Useful? React with 👍 / 👎.


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) {

Expand All @@ -260,20 +300,33 @@ public void onDestroyView() {
binding = null;
}

@Override
public void onRepoLoaded() {
refreshModuleFromRepo();
loadRemoteModuleIfReadmeMissing();
if (releaseAdapter != null) {
runAsync(releaseAdapter::loadItems);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve loaded releases on repo refresh

When a repo refresh arrives after this fragment has already fetched full module details, refreshModuleFromRepo() intentionally keeps the detailed module, but this reload still calls ReleaseAdapter.loadItems(), which first reads RepoLoader.getReleases(module.getName()). The refresh has just replaced RepoLoader's map with the modules.json summary, so the adapter is repopulated with the truncated summary release list while module.releasesLoaded remains true, removing the load-more row and hiding older releases. Prefer the detailed module when it is already loaded, or skip this reload in that case.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve full release data on repo refresh

When a repo refresh arrives after this fragment has already loaded full module details, refreshModuleFromRepo() keeps module.releasesLoaded == true, but this reload calls ReleaseAdapter.loadItems(), which reads from RepoLoader.getReleases() first. The loader entry has just been replaced with the modules.json summary, so the adapter can show only the truncated summary releases while getItemCount() still suppresses the load-more row based on the detailed module, leaving users unable to load the missing releases after a background/full refresh.

Useful? React with 👍 / 👎.

}
}

@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
public void onThrowable(Throwable t) {
remoteModuleLoadRequested = false;
releaseLoadRequestedByUser = false;
if (releaseAdapter != null) {
runAsync(releaseAdapter::loadItems);
}
Expand Down Expand Up @@ -456,6 +509,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());
}
});
Expand Down Expand Up @@ -611,9 +665,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) {
Expand All @@ -624,13 +686,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());
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 onDestroyView() {
RepoLoader.getInstance().removeListener(this);
binding = null;
super.onDestroyView();
}

@Override
void scrollToTop() {
binding.scrollView.fullScroll(ScrollView.FOCUS_UP);
Expand Down