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..b2c4efeb2 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,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 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); @@ -98,37 +100,69 @@ 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"); + OnlineModule[] repoModules = parseRepoModules(bodyString); + Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8)); + repoUrl = candidateRepoUrl; + 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) { + 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; + 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; } } @@ -141,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) { @@ -248,49 +277,77 @@ 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, 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, 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()); - 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, attempts, 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, attempts, 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, attempts, t); } } }); } + 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); + } + } + } + public void addListener(RepoListener listener) { listeners.add(listener); } diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java index 3dcab2aa2..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 @@ -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,51 @@ 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()); + } + + @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 +305,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 +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])) { @@ -456,6 +524,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 +680,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 +701,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);