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)
This commit is contained in:
BuildTools 2023-08-12 15:37:41 +03:00 committed by ArtDev
parent 5fde03dbaa
commit b430edbc0b
8 changed files with 336 additions and 44 deletions

View File

@ -20,6 +20,8 @@ import net.kdt.pojavlaunch.PojavApplication;
import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.modloaders.modpacks.ModItemAdapter; 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.ModpackApi;
import net.kdt.pojavlaunch.modloaders.modpacks.api.ModrinthApi; import net.kdt.pojavlaunch.modloaders.modpacks.api.ModrinthApi;
import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem;
@ -50,7 +52,7 @@ public class SearchModFragment extends Fragment {
public SearchModFragment(){ public SearchModFragment(){
super(R.layout.fragment_mod_search); super(R.layout.fragment_mod_search);
modpackApi = new ModrinthApi(); modpackApi = new CurseforgeApi();
mModItemAdapter = new ModItemAdapter(modpackApi); mModItemAdapter = new ModItemAdapter(modpackApi);
mSearchFilters = new SearchFilters(); mSearchFilters = new SearchFilters();
mSearchFilters.isModpack = true; mSearchFilters.isModpack = true;
@ -82,43 +84,26 @@ public class SearchModFragment extends Fragment {
} }
mSearchProgressBar.setVisibility(View.VISIBLE); mSearchProgressBar.setVisibility(View.VISIBLE);
mSearchFilters.name = mSearchEditText.getText().toString(); mSearchFilters.name = mSearchEditText.getText().toString();
SearchModRunnable searchModRunnable = new SearchModRunnable(mSearchFilters); mSearchFuture = new SelfReferencingFuture(new SearchModTask(mSearchFilters))
mSearchFuture = PojavApplication.sExecutorService.submit(searchModRunnable); .startOnExecutor(PojavApplication.sExecutorService);
searchModRunnable.setFuture(mSearchFuture);
return true; return true;
}); });
} }
class SearchModRunnable implements Runnable{ class SearchModTask implements SelfReferencingFuture.FutureInterface {
private final Object mFutureLock = new Object();
private final SearchFilters mRunnableFilters;
private Future<?> mMyFuture;
SearchModRunnable(SearchFilters mSearchFilters) { private final SearchFilters mTaskFilters;
this.mRunnableFilters = mSearchFilters; SearchModTask(SearchFilters mSearchFilters) {
} this.mTaskFilters = mSearchFilters;
public void setFuture(Future<?> future) {
synchronized (mFutureLock) {
mMyFuture = future;
mFutureLock.notifyAll();
}
} }
@Override @Override
public void run() { public void run(Future<?> myFuture) {
synchronized (mFutureLock) { ModItem[] items = modpackApi.searchMod(mTaskFilters);
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);
Log.d(SearchModFragment.class.toString(), Arrays.toString(items)); Log.d(SearchModFragment.class.toString(), Arrays.toString(items));
Tools.runOnUiThread(() -> { Tools.runOnUiThread(() -> {
ModItem[] localItems = items; ModItem[] localItems = items;
if(mMyFuture.isCancelled()) return; if(myFuture.isCancelled()) return;
mSearchProgressBar.setVisibility(View.GONE); mSearchProgressBar.setVisibility(View.GONE);
if(localItems == null) { if(localItems == null) {
mStatusTextView.setVisibility(View.VISIBLE); mStatusTextView.setVisibility(View.VISIBLE);

View File

@ -26,6 +26,7 @@ import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail;
import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem;
import java.util.Arrays; import java.util.Arrays;
import java.util.concurrent.Future;
public class ModItemAdapter extends RecyclerView.Adapter<ModItemAdapter.ViewHolder> { public class ModItemAdapter extends RecyclerView.Adapter<ModItemAdapter.ViewHolder> {
private static final ModItem[] MOD_ITEMS_EMPTY = new ModItem[0]; private static final ModItem[] MOD_ITEMS_EMPTY = new ModItem[0];
@ -41,10 +42,12 @@ public class ModItemAdapter extends RecyclerView.Adapter<ModItemAdapter.ViewHold
private ModDetail mModDetail = null; private ModDetail mModDetail = null;
private ModItem mModItem = null; private ModItem mModItem = null;
private final TextView mTitle, mDescription; private final TextView mTitle, mDescription;
private final ImageView mIconView;
private View mExtendedLayout; private View mExtendedLayout;
private Spinner mExtendedSpinner; private Spinner mExtendedSpinner;
private Button mExtendedButton; private Button mExtendedButton;
private final ImageView mIconView; private TextView mExtendedErrorTextView;
private Future<?> mExtensionFuture;
private Bitmap mThumbnailBitmap; private Bitmap mThumbnailBitmap;
private ImageReceiver mImageReceiver; private ImageReceiver mImageReceiver;
public ViewHolder(View view) { public ViewHolder(View view) {
@ -56,6 +59,7 @@ public class ModItemAdapter extends RecyclerView.Adapter<ModItemAdapter.ViewHold
mExtendedLayout = ((ViewStub)v.findViewById(R.id.mod_limited_state_stub)).inflate(); mExtendedLayout = ((ViewStub)v.findViewById(R.id.mod_limited_state_stub)).inflate();
mExtendedButton = mExtendedLayout.findViewById(R.id.mod_extended_select_version_button); mExtendedButton = mExtendedLayout.findViewById(R.id.mod_extended_select_version_button);
mExtendedSpinner = mExtendedLayout.findViewById(R.id.mod_extended_version_spinner); mExtendedSpinner = mExtendedLayout.findViewById(R.id.mod_extended_version_spinner);
mExtendedErrorTextView = mExtendedLayout.findViewById(R.id.mod_extended_error_textview);
mExtendedButton.setOnClickListener(v1 -> { mExtendedButton.setOnClickListener(v1 -> {
mModpackApi.handleInstallation( mModpackApi.handleInstallation(
@ -69,12 +73,39 @@ public class ModItemAdapter extends RecyclerView.Adapter<ModItemAdapter.ViewHold
else mExtendedLayout.setVisibility(View.VISIBLE); else mExtendedLayout.setVisibility(View.VISIBLE);
} }
if(isExtended() && mModDetail == null) { if(isExtended() && mModDetail == null && mExtensionFuture == null) { // only reload if no reloads are in progress
PojavApplication.sExecutorService.execute(() -> { 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); mModDetail = mModpackApi.getModDetails(mModItem);
System.out.println(mModDetail); 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<ModItemAdapter.ViewHold
if(mImageReceiver != null) { if(mImageReceiver != null) {
mIconCache.cancelImage(mImageReceiver); mIconCache.cancelImage(mImageReceiver);
} }
if(mExtensionFuture != null) {
/*
* Since this method reinitializes the ViewHolder for a new mod, this Future stops being ours, so we cancel it
* and null it. The rest is handled above
*/
mExtensionFuture.cancel(true);
mExtensionFuture = null;
}
mModItem = item; mModItem = item;
// here the previous reference to the image receiver will disappear // here the previous reference to the image receiver will disappear
@ -114,7 +153,22 @@ public class ModItemAdapter extends RecyclerView.Adapter<ModItemAdapter.ViewHold
/** Display extended info/interaction about a modpack */ /** Display extended info/interaction about a modpack */
private void setStateDetailed(ModDetail detailedItem) { private void setStateDetailed(ModDetail detailedItem) {
mExtendedLayout.setVisibility(View.VISIBLE); mExtendedLayout.setVisibility(View.VISIBLE);
mExtendedSpinner.setAdapter(new SimpleArrayAdapter<>(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(){ private boolean hasExtended(){

View File

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

View File

@ -1,5 +1,6 @@
package net.kdt.pojavlaunch.modloaders.modpacks.api; package net.kdt.pojavlaunch.modloaders.modpacks.api;
import android.util.ArrayMap;
import android.util.Log; import android.util.Log;
import com.google.gson.Gson; import com.google.gson.Gson;
@ -13,35 +14,50 @@ import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map;
@SuppressWarnings("unused")
public class ApiHandler { public class ApiHandler {
public final String baseUrl; public final String baseUrl;
public final Map<String, String> additionalHeaders;
public ApiHandler(String url) { public ApiHandler(String url) {
baseUrl = url; baseUrl = url;
additionalHeaders = null;
}
public ApiHandler(String url, String apiKey) {
baseUrl = url;
additionalHeaders = new ArrayMap<>();
additionalHeaders.put("x-api-key", apiKey);
} }
public <T> T get(String endpoint, Class<T> tClass) { public <T> T get(String endpoint, Class<T> tClass) {
return getFullUrl(baseUrl + "/" + endpoint, tClass); return getFullUrl(additionalHeaders, baseUrl + "/" + endpoint, tClass);
} }
public <T> T get(String endpoint, HashMap<String, Object> query, Class<T> tClass) { public <T> T get(String endpoint, HashMap<String, Object> query, Class<T> tClass) {
return getFullUrl(baseUrl + "/" + endpoint, query, tClass); return getFullUrl(additionalHeaders, baseUrl + "/" + endpoint, query, tClass);
} }
public <T> T post(String endpoint, T body, Class<T> tClass) { public <T> T post(String endpoint, T body, Class<T> tClass) {
return postFullUrl(baseUrl + "/" + endpoint, body, tClass); return postFullUrl(additionalHeaders, baseUrl + "/" + endpoint, body, tClass);
} }
public <T> T post(String endpoint, HashMap<String, Object> query, T body, Class<T> tClass) { public <T> T post(String endpoint, HashMap<String, Object> query, T body, Class<T> 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; //Make a get request and return the response as a raw string;
public static String getRaw(String url) { public static String getRaw(String url) {
return getRaw(null, url);
}
public static String getRaw(Map<String, String> headers, String url) {
Log.d("ApiHandler", url); Log.d("ApiHandler", url);
try { try {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
addHeaders(conn, headers);
InputStream inputStream = conn.getInputStream(); InputStream inputStream = conn.getInputStream();
String data = Tools.read(inputStream); String data = Tools.read(inputStream);
Log.d(ApiHandler.class.toString(), data); Log.d(ApiHandler.class.toString(), data);
@ -55,11 +71,16 @@ public class ApiHandler {
} }
public static String postRaw(String url, String body) { public static String postRaw(String url, String body) {
return postRaw(null, url, body);
}
public static String postRaw(Map<String, String> headers, String url, String body) {
try { try {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("Accept", "application/json");
addHeaders(conn, headers);
conn.setDoOutput(true); conn.setDoOutput(true);
OutputStream outputStream = conn.getOutputStream(); OutputStream outputStream = conn.getOutputStream();
@ -79,6 +100,13 @@ public class ApiHandler {
return null; return null;
} }
private static void addHeaders(HttpURLConnection connection, Map<String, String> headers) {
if(headers != null) {
for(String key : headers.keySet())
connection.addRequestProperty(key, headers.get(key));
}
}
private static String parseQueries(HashMap<String, Object> query) { private static String parseQueries(HashMap<String, Object> query) {
StringBuilder params = new StringBuilder("?"); StringBuilder params = new StringBuilder("?");
for (String param : query.keySet()) { for (String param : query.keySet()) {
@ -89,18 +117,34 @@ public class ApiHandler {
} }
public static <T> T getFullUrl(String url, Class<T> tClass) { public static <T> T getFullUrl(String url, Class<T> tClass) {
return new Gson().fromJson(getRaw(url), tClass); return getFullUrl(null, url, tClass);
} }
public static <T> T getFullUrl(String url, HashMap<String, Object> query, Class<T> tClass) { public static <T> T getFullUrl(String url, HashMap<String, Object> query, Class<T> tClass) {
return getFullUrl(url + parseQueries(query), tClass); return getFullUrl(null, url, query, tClass);
} }
public static <T> T postFullUrl(String url, T body, Class<T> tClass) { public static <T> T postFullUrl(String url, T body, Class<T> tClass) {
return new Gson().fromJson(postRaw(url, body.toString()), tClass); return postFullUrl(null, url, body, tClass);
} }
public static <T> T postFullUrl(String url, HashMap<String, Object> query, T body, Class<T> tClass) { public static <T> T postFullUrl(String url, HashMap<String, Object> query, T body, Class<T> tClass) {
return new Gson().fromJson(postRaw(url + parseQueries(query), body.toString()), tClass); return postFullUrl(null, url, query, body, tClass);
}
public static <T> T getFullUrl(Map<String, String> headers, String url, Class<T> tClass) {
return new Gson().fromJson(getRaw(headers, url), tClass);
}
public static <T> T getFullUrl(Map<String, String> headers, String url, HashMap<String, Object> query, Class<T> tClass) {
return getFullUrl(headers, url + parseQueries(query), tClass);
}
public static <T> T postFullUrl(Map<String, String> headers, String url, T body, Class<T> tClass) {
return new Gson().fromJson(postRaw(headers, url, body.toString()), tClass);
}
public static <T> T postFullUrl(Map<String, String> headers, String url, HashMap<String, Object> query, T body, Class<T> tClass) {
return new Gson().fromJson(postRaw(headers, url + parseQueries(query), body.toString()), tClass);
} }
} }

View File

@ -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<String, Object> 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<ModItem> 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<JsonObject> 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<JsonObject> objectList, int index, String modId) {
HashMap<String, Object> 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;
}
}

View File

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

View File

@ -6,11 +6,20 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<TextView
android:id="@+id/mod_extended_error_textview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/search_modpack_download_error"
android:textColor="#FFFF0000"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatSpinner <androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/mod_extended_version_spinner" android:id="@+id/mod_extended_version_spinner"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="@dimen/_30sdp" android:layout_height="@dimen/_30sdp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toBottomOf="@+id/mod_extended_error_textview"
/> />
<com.kdt.mcgui.MineButton <com.kdt.mcgui.MineButton
@ -18,9 +27,9 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginVertical="@dimen/padding_large" android:layout_marginVertical="@dimen/padding_large"
android:enabled="false"
android:text="@string/generic_install" android:text="@string/generic_install"
app:layout_constraintTop_toBottomOf="@id/mod_extended_version_spinner"
/> app:layout_constraintTop_toBottomOf="@id/mod_extended_version_spinner" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -412,6 +412,8 @@
<string name="search_modpack_no_result">No modpacks found</string> <string name="search_modpack_no_result">No modpacks found</string>
<string name="search_modpack_error">Failed to find modpacks</string> <string name="search_modpack_error">Failed to find modpacks</string>
<string name="search_modpack_download_error">Failed to download modpack metadata</string>
<string name="modpack_download_downloading_metadata">Downloading modpack metadata (%.2f MB / %.2f MB)</string> <string name="modpack_download_downloading_metadata">Downloading modpack metadata (%.2f MB / %.2f MB)</string>
<string name="modpack_download_downloading_mods">Downloading mods (%.2f MB / %.2f MB)</string> <string name="modpack_download_downloading_mods">Downloading mods (%.2f MB / %.2f MB)</string>
<string name="modpack_download_applying_overrides">Applying overrides (%d/%d)</string> <string name="modpack_download_applying_overrides">Applying overrides (%d/%d)</string>