From b430edbc0b89f85ac0de4b3208ec50324d5043eb Mon Sep 17 00:00:00 2001 From: BuildTools Date: Sat, 12 Aug 2023 15:37:41 +0300 Subject: [PATCH] Async changes + WIP CurseForge implementation - Implemented IOException handling and reliable Futures for the ModItemAdapter - Generified self-referencing Futures into a SelfReferencingFuture class - Added Work-in-progress CurseForge implementation TODO: - Handle Exceptions from the modpack install process - Install the modloader after finishing instance installation - Finish CurseForge support (current roadblock: lack of documentation) --- .../fragments/SearchModFragment.java | 39 ++--- .../modloaders/modpacks/ModItemAdapter.java | 66 ++++++++- .../modpacks/SelfReferencingFuture.java | 40 ++++++ .../modloaders/modpacks/api/ApiHandler.java | 60 ++++++-- .../modpacks/api/CurseforgeApi.java | 134 ++++++++++++++++++ .../modpacks/models/CurseManifest.java | 24 ++++ .../src/main/res/layout/view_mod_extended.xml | 15 +- .../src/main/res/values/strings.xml | 2 + 8 files changed, 336 insertions(+), 44 deletions(-) create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/SelfReferencingFuture.java create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/CurseManifest.java diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java index 24af84154..0b17421c9 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java @@ -20,6 +20,8 @@ import net.kdt.pojavlaunch.PojavApplication; import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.modloaders.modpacks.ModItemAdapter; +import net.kdt.pojavlaunch.modloaders.modpacks.SelfReferencingFuture; +import net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi; import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi; import net.kdt.pojavlaunch.modloaders.modpacks.api.ModrinthApi; import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; @@ -50,7 +52,7 @@ public class SearchModFragment extends Fragment { public SearchModFragment(){ super(R.layout.fragment_mod_search); - modpackApi = new ModrinthApi(); + modpackApi = new CurseforgeApi(); mModItemAdapter = new ModItemAdapter(modpackApi); mSearchFilters = new SearchFilters(); mSearchFilters.isModpack = true; @@ -82,43 +84,26 @@ public class SearchModFragment extends Fragment { } mSearchProgressBar.setVisibility(View.VISIBLE); mSearchFilters.name = mSearchEditText.getText().toString(); - SearchModRunnable searchModRunnable = new SearchModRunnable(mSearchFilters); - mSearchFuture = PojavApplication.sExecutorService.submit(searchModRunnable); - searchModRunnable.setFuture(mSearchFuture); + mSearchFuture = new SelfReferencingFuture(new SearchModTask(mSearchFilters)) + .startOnExecutor(PojavApplication.sExecutorService); return true; }); } - class SearchModRunnable implements Runnable{ - private final Object mFutureLock = new Object(); - private final SearchFilters mRunnableFilters; - private Future mMyFuture; + class SearchModTask implements SelfReferencingFuture.FutureInterface { - SearchModRunnable(SearchFilters mSearchFilters) { - this.mRunnableFilters = mSearchFilters; - } - - public void setFuture(Future future) { - synchronized (mFutureLock) { - mMyFuture = future; - mFutureLock.notifyAll(); - } + private final SearchFilters mTaskFilters; + SearchModTask(SearchFilters mSearchFilters) { + this.mTaskFilters = mSearchFilters; } @Override - public void run() { - synchronized (mFutureLock) { - try { - if (mMyFuture == null) mFutureLock.wait(); - }catch (InterruptedException e) { - return; // if we got interrupted on the future lock theres no point in proceeding - } - } - ModItem[] items = modpackApi.searchMod(mRunnableFilters); + public void run(Future myFuture) { + ModItem[] items = modpackApi.searchMod(mTaskFilters); Log.d(SearchModFragment.class.toString(), Arrays.toString(items)); Tools.runOnUiThread(() -> { ModItem[] localItems = items; - if(mMyFuture.isCancelled()) return; + if(myFuture.isCancelled()) return; mSearchProgressBar.setVisibility(View.GONE); if(localItems == null) { mStatusTextView.setVisibility(View.VISIBLE); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java index ad6a5f049..bec028ac4 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java @@ -26,6 +26,7 @@ import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail; import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; import java.util.Arrays; +import java.util.concurrent.Future; public class ModItemAdapter extends RecyclerView.Adapter { private static final ModItem[] MOD_ITEMS_EMPTY = new ModItem[0]; @@ -41,10 +42,12 @@ public class ModItemAdapter extends RecyclerView.Adapter mExtensionFuture; private Bitmap mThumbnailBitmap; private ImageReceiver mImageReceiver; public ViewHolder(View view) { @@ -56,6 +59,7 @@ public class ModItemAdapter extends RecyclerView.Adapter { mModpackApi.handleInstallation( @@ -69,12 +73,39 @@ public class ModItemAdapter extends RecyclerView.Adapter { + if(isExtended() && mModDetail == null && mExtensionFuture == null) { // only reload if no reloads are in progress + setDetailedStateDefault(); + /* + * Why do we do this? + * The reason is simple: multithreading is difficult as hell to manage + * Let me explain: + */ + mExtensionFuture = new SelfReferencingFuture(myFuture -> { + /* + * While we are sitting in the function below doing networking, the view might have already gotten recycled. + * If we didn't use a Future, we would have extended a ViewHolder with completely unrelated content + * or with an error that has never actually happened + */ mModDetail = mModpackApi.getModDetails(mModItem); System.out.println(mModDetail); - Tools.runOnUiThread(() -> setStateDetailed(mModDetail)); - }); + Tools.runOnUiThread(() -> { + /* + * Once we enter here, the state we're in is already defined - no view shuffling can happen on the UI + * thread while we are on the UI thread ourselves. If we were cancelled, this means that the future + * we were supposed to have no longer makes sense, so we return and do not alter the state (since we might + * alter the state of an unrelated item otherwise) + */ + if(myFuture.isCancelled()) return; + /* + * We do not null the future before returning since this field might already belong to a different item with its + * own Future, which we don't want to interfere with. + * But if the future is not cancelled, it is the right one for this ViewHolder, and we don't need it anymore, so + * let's help GC clean it up once we exit! + */ + mExtensionFuture = null; + setStateDetailed(mModDetail); + }); + }).startOnExecutor(PojavApplication.sExecutorService); } }); @@ -94,6 +125,14 @@ public class ModItemAdapter extends RecyclerView.Adapter(Arrays.asList(detailedItem.versionNames))); + if(detailedItem != null) { + mExtendedButton.setEnabled(true); + mExtendedErrorTextView.setVisibility(View.GONE); + mExtendedSpinner.setAdapter(new SimpleArrayAdapter<>(Arrays.asList(detailedItem.versionNames))); + } else { + mExtendedButton.setEnabled(false); + mExtendedErrorTextView.setVisibility(View.VISIBLE); + mExtendedSpinner.setAdapter(null); + } + + } + + private void setDetailedStateDefault() { + mExtendedButton.setEnabled(false); + mExtendedSpinner.setAdapter(null); + mExtendedErrorTextView.setVisibility(View.GONE); } private boolean hasExtended(){ diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/SelfReferencingFuture.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/SelfReferencingFuture.java new file mode 100644 index 000000000..6f7d625af --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/SelfReferencingFuture.java @@ -0,0 +1,40 @@ +package net.kdt.pojavlaunch.modloaders.modpacks; + +import android.util.Log; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +public class SelfReferencingFuture { + private final Object mFutureLock = new Object(); + private final FutureInterface mFutureInterface; + private Future mMyFuture; + + public SelfReferencingFuture(FutureInterface futureInterface) { + this.mFutureInterface = futureInterface; + } + + public Future startOnExecutor(ExecutorService executorService) { + Future future = executorService.submit(this::run); + synchronized (mFutureLock) { + mMyFuture = future; + mFutureLock.notify(); + } + return future; + } + + private void run() { + try { + synchronized (mFutureLock) { + if (mMyFuture == null) mFutureLock.wait(); + } + mFutureInterface.run(mMyFuture); + }catch (InterruptedException e) { + Log.i("SelfReferencingFuture", "Interrupted while acquiring own Future"); + } + } + + public interface FutureInterface { + void run(Future myFuture); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java index 07aaa05c7..6de53db0a 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java @@ -1,5 +1,6 @@ package net.kdt.pojavlaunch.modloaders.modpacks.api; +import android.util.ArrayMap; import android.util.Log; import com.google.gson.Gson; @@ -13,35 +14,50 @@ import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.HashMap; +import java.util.Map; +@SuppressWarnings("unused") public class ApiHandler { public final String baseUrl; + public final Map additionalHeaders; public ApiHandler(String url) { baseUrl = url; + additionalHeaders = null; + } + + public ApiHandler(String url, String apiKey) { + baseUrl = url; + additionalHeaders = new ArrayMap<>(); + additionalHeaders.put("x-api-key", apiKey); } public T get(String endpoint, Class tClass) { - return getFullUrl(baseUrl + "/" + endpoint, tClass); + return getFullUrl(additionalHeaders, baseUrl + "/" + endpoint, tClass); } public T get(String endpoint, HashMap query, Class tClass) { - return getFullUrl(baseUrl + "/" + endpoint, query, tClass); + return getFullUrl(additionalHeaders, baseUrl + "/" + endpoint, query, tClass); } public T post(String endpoint, T body, Class tClass) { - return postFullUrl(baseUrl + "/" + endpoint, body, tClass); + return postFullUrl(additionalHeaders, baseUrl + "/" + endpoint, body, tClass); } public T post(String endpoint, HashMap query, T body, Class tClass) { - return postFullUrl(baseUrl + "/" + endpoint, query, body, tClass); + return postFullUrl(additionalHeaders, baseUrl + "/" + endpoint, query, body, tClass); } //Make a get request and return the response as a raw string; public static String getRaw(String url) { + return getRaw(null, url); + } + + public static String getRaw(Map headers, String url) { Log.d("ApiHandler", url); try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + addHeaders(conn, headers); InputStream inputStream = conn.getInputStream(); String data = Tools.read(inputStream); Log.d(ApiHandler.class.toString(), data); @@ -55,11 +71,16 @@ public class ApiHandler { } public static String postRaw(String url, String body) { + return postRaw(null, url, body); + } + + public static String postRaw(Map headers, String url, String body) { try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Accept", "application/json"); + addHeaders(conn, headers); conn.setDoOutput(true); OutputStream outputStream = conn.getOutputStream(); @@ -79,6 +100,13 @@ public class ApiHandler { return null; } + private static void addHeaders(HttpURLConnection connection, Map headers) { + if(headers != null) { + for(String key : headers.keySet()) + connection.addRequestProperty(key, headers.get(key)); + } + } + private static String parseQueries(HashMap query) { StringBuilder params = new StringBuilder("?"); for (String param : query.keySet()) { @@ -89,18 +117,34 @@ public class ApiHandler { } public static T getFullUrl(String url, Class tClass) { - return new Gson().fromJson(getRaw(url), tClass); + return getFullUrl(null, url, tClass); } public static T getFullUrl(String url, HashMap query, Class tClass) { - return getFullUrl(url + parseQueries(query), tClass); + return getFullUrl(null, url, query, tClass); } public static T postFullUrl(String url, T body, Class tClass) { - return new Gson().fromJson(postRaw(url, body.toString()), tClass); + return postFullUrl(null, url, body, tClass); } public static T postFullUrl(String url, HashMap query, T body, Class tClass) { - return new Gson().fromJson(postRaw(url + parseQueries(query), body.toString()), tClass); + return postFullUrl(null, url, query, body, tClass); + } + + public static T getFullUrl(Map headers, String url, Class tClass) { + return new Gson().fromJson(getRaw(headers, url), tClass); + } + + public static T getFullUrl(Map headers, String url, HashMap query, Class tClass) { + return getFullUrl(headers, url + parseQueries(query), tClass); + } + + public static T postFullUrl(Map headers, String url, T body, Class tClass) { + return new Gson().fromJson(postRaw(headers, url, body.toString()), tClass); + } + + public static T postFullUrl(Map headers, String url, HashMap query, T body, Class tClass) { + return new Gson().fromJson(postRaw(headers, url + parseQueries(query), body.toString()), tClass); } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java new file mode 100644 index 000000000..8773c81ba --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java @@ -0,0 +1,134 @@ +package net.kdt.pojavlaunch.modloaders.modpacks.api; + +import android.util.Log; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants; +import net.kdt.pojavlaunch.modloaders.modpacks.models.CurseManifest; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModrinthIndex; +import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; +import net.kdt.pojavlaunch.utils.ZipUtils; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.regex.Pattern; +import java.util.zip.ZipFile; + +public class CurseforgeApi implements ModpackApi{ + private static final Pattern sMcVersionPattern = Pattern.compile("([0-9]+)\\.([0-9]+)\\.?([0-9]+)?"); + // Stolen from + // https://github.com/AnzhiZhang/CurseForgeModpackDownloader/blob/6cb3f428459f0cc8f444d16e54aea4cd1186fd7b/utils/requester.py#L93 + private static final int CURSEFORGE_MINECRAFT_GAME_ID = 432; + private static final int CURSEFORGE_MODPACK_CLASS_ID = 4471; + // https://api.curseforge.com/v1/categories?gameId=432 and search for "Mods" (case-sensitive) + private static final int CURSEFORGE_MOD_CLASS_ID = 6; + private static final int CURSEFORGE_PAGINATION_SIZE = 50; + + private final ApiHandler mApiHandler = new ApiHandler("https://api.curseforge.com/v1", "$2a$10$Vxkj4kH1Ekf8EsS4Mx8b2eVTHsht107Lk2erVEUtnbqvojsLy.jYq"); + + @Override + public ModItem[] searchMod(SearchFilters searchFilters) { + HashMap params = new HashMap<>(); + params.put("gameId", CURSEFORGE_MINECRAFT_GAME_ID); + params.put("classId", searchFilters.isModpack ? CURSEFORGE_MODPACK_CLASS_ID : CURSEFORGE_MOD_CLASS_ID); + params.put("searchFilter", searchFilters.name); + if(searchFilters.mcVersion != null && !searchFilters.mcVersion.isEmpty()) + params.put("gameVersion", searchFilters.mcVersion); + JsonObject response = mApiHandler.get("mods/search", params, JsonObject.class); + if(response == null) return null; + JsonArray dataArray = response.getAsJsonArray("data"); + if(dataArray == null) return null; + ArrayList modItemList = new ArrayList<>(dataArray.size()); + for(int i = 0; i < dataArray.size(); i++) { + JsonObject dataElement = dataArray.get(i).getAsJsonObject(); + JsonElement allowModDistribution = dataElement.get("allowModDistribution"); + // Gson automatically casts null to false, which leans to issues + // So, only check the distribution flag if it is non-null + if(!allowModDistribution.isJsonNull() && !allowModDistribution.getAsBoolean()) { + Log.i("CurseforgeApi", "Skipping modpack "+dataElement.get("name").getAsString() + " because curseforge sucks"); + continue; + } + ModItem modItem = new ModItem(Constants.SOURCE_CURSEFORGE, + searchFilters.isModpack, + dataElement.get("id").getAsString(), + dataElement.get("name").getAsString(), + dataElement.get("summary").getAsString(), + dataElement.getAsJsonObject("logo").get("thumbnailUrl").getAsString()); + modItemList.add(modItem); + } + return modItemList.toArray(new ModItem[0]); + } + + @Override + public ModDetail getModDetails(ModItem item) { + ArrayList allModDetails = new ArrayList<>(); + int index = 0; + while(index != -1 && index != -2) { + index = getPaginatedDetails(allModDetails, index, item.id); + } + if(index == -2) return null; + int length = allModDetails.size(); + String[] versionNames = new String[length]; + String[] mcVersionNames = new String[length]; + String[] versionUrls = new String[length]; + for(int i = 0; i < allModDetails.size(); i++) { + JsonObject modDetail = allModDetails.get(i); + versionNames[i] = modDetail.get("displayName").getAsString(); + JsonElement downloadUrl = modDetail.get("downloadUrl"); + versionUrls[i] = downloadUrl.getAsString(); + JsonArray gameVersions = modDetail.getAsJsonArray("gameVersions"); + for(JsonElement jsonElement : gameVersions) { + String gameVersion = jsonElement.getAsString(); + if(!sMcVersionPattern.matcher(gameVersion).matches()) { + continue; + } + mcVersionNames[i] = gameVersion; + break; + } + } + return new ModDetail(item, versionNames, mcVersionNames, versionUrls); + } + + @Override + public void installMod(ModDetail modDetail, int selectedVersion) { + String versionUrl = modDetail.versionUrls[selectedVersion]; + File modpackFile = new File(Tools.DIR_CACHE, modDetail.id+".zip"); + } + + + private int getPaginatedDetails(ArrayList objectList, int index, String modId) { + HashMap params = new HashMap<>(); + params.put("index", index); + params.put("pageSize", CURSEFORGE_PAGINATION_SIZE); + JsonObject response = mApiHandler.get("mods/"+modId+"/files", params, JsonObject.class); + if(response == null) return -2; + JsonArray data = response.getAsJsonArray("data"); + if(data == null) return -2; + for(int i = 0; i < data.size(); i++) { + JsonObject fileInfo = data.get(i).getAsJsonObject(); + if(fileInfo.get("isServerPack").getAsBoolean()) continue; + objectList.add(fileInfo); + } + if(data.size() < CURSEFORGE_PAGINATION_SIZE) { + return -1; // we read the remainder! yay! + } + return index + data.size(); + } + + private ModLoaderInfo installCurseforgeZip(File zipFile, File instanceDestination) throws IOException { + try (ZipFile modpackZipFile = new ZipFile(zipFile)){ + CurseManifest curseManifest = Tools.GLOBAL_GSON.fromJson( + Tools.read(ZipUtils.getEntryStream(modpackZipFile, "manifest.json")), + CurseManifest.class); + } + return null; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/CurseManifest.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/CurseManifest.java new file mode 100644 index 000000000..2079cc81b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/CurseManifest.java @@ -0,0 +1,24 @@ +package net.kdt.pojavlaunch.modloaders.modpacks.models; + +public class CurseManifest { + public String name; + public String version; + public String author; + public String manifestType; + public int manifestVersion; + public CurseFile[] files; + public CurseMinecraft minecraft; + public String overrides; + public static class CurseFile { + public long projectID; + public long fileID; + } + public static class CurseMinecraft { + public String version; + public CurseModLoader[] modLoaders; + } + public static class CurseModLoader { + public String id; + public boolean primary; + } +} diff --git a/app_pojavlauncher/src/main/res/layout/view_mod_extended.xml b/app_pojavlauncher/src/main/res/layout/view_mod_extended.xml index 1be59a5f0..6d4ae6436 100644 --- a/app_pojavlauncher/src/main/res/layout/view_mod_extended.xml +++ b/app_pojavlauncher/src/main/res/layout/view_mod_extended.xml @@ -6,11 +6,20 @@ xmlns:tools="http://schemas.android.com/tools"> + + + app:layout_constraintTop_toBottomOf="@id/mod_extended_version_spinner" /> \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/values/strings.xml b/app_pojavlauncher/src/main/res/values/strings.xml index d9cfb92d6..3d3526213 100644 --- a/app_pojavlauncher/src/main/res/values/strings.xml +++ b/app_pojavlauncher/src/main/res/values/strings.xml @@ -412,6 +412,8 @@ No modpacks found Failed to find modpacks + Failed to download modpack metadata + Downloading modpack metadata (%.2f MB / %.2f MB) Downloading mods (%.2f MB / %.2f MB) Applying overrides (%d/%d)