diff --git a/app_pojavlauncher/build.gradle b/app_pojavlauncher/build.gradle
index 8cc5e59c6..8dc7417ed 100644
--- a/app_pojavlauncher/build.gradle
+++ b/app_pojavlauncher/build.gradle
@@ -67,6 +67,17 @@ def getVersionName = {
return TAG_STRING.trim().replace("-g", "-") + "-" + BRANCH.toString().trim()
}
+def getCFApiKey = {
+ String key = System.getenv("CURSEFORGE_API_KEY");
+ if(key != null) return key;
+ File curseforgeKeyFile = new File("./curseforge_key.txt");
+ if(curseforgeKeyFile.canRead() && curseforgeKeyFile.isFile()) {
+ return curseforgeKeyFile.text;
+ }
+ logger.warn('BUILD: You have no CurseForge key, the curseforge api will get disabled !');
+ return "DUMMY";
+}
+
configurations {
instrumentedClasspath {
canBeConsumed = false
@@ -105,6 +116,7 @@ android {
versionCode getDateSeconds()
versionName getVersionName()
multiDexEnabled true //important
+ resValue 'string', 'curseforge_api_key', getCFApiKey()
}
buildTypes {
diff --git a/app_pojavlauncher/src/main/AndroidManifest.xml b/app_pojavlauncher/src/main/AndroidManifest.xml
index 457e0cbfd..4147e1f3a 100644
--- a/app_pojavlauncher/src/main/AndroidManifest.xml
+++ b/app_pojavlauncher/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
android:name="android.hardware.type.pc"
android:required="false" />
+
@@ -77,11 +78,15 @@
android:name=".FatalErrorActivity"
android:configChanges="keyboardHidden|orientation|screenSize|keyboard|navigation"
android:theme="@style/Theme.AppCompat.DayNight.Dialog" />
+
diff --git a/app_pojavlauncher/src/main/assets/components/forge_installer/forge_installer.jar b/app_pojavlauncher/src/main/assets/components/forge_installer/forge_installer.jar
index 2e498a39b..c540bee9c 100644
Binary files a/app_pojavlauncher/src/main/assets/components/forge_installer/forge_installer.jar and b/app_pojavlauncher/src/main/assets/components/forge_installer/forge_installer.jar differ
diff --git a/app_pojavlauncher/src/main/assets/components/forge_installer/version b/app_pojavlauncher/src/main/assets/components/forge_installer/version
index 825b64780..50f426278 100644
--- a/app_pojavlauncher/src/main/assets/components/forge_installer/version
+++ b/app_pojavlauncher/src/main/assets/components/forge_installer/version
@@ -1 +1 @@
-1688133008591
\ No newline at end of file
+1692525087345
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/com/kdt/SimpleArrayAdapter.java b/app_pojavlauncher/src/main/java/com/kdt/SimpleArrayAdapter.java
new file mode 100644
index 000000000..4069ae429
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/com/kdt/SimpleArrayAdapter.java
@@ -0,0 +1,67 @@
+package com.kdt;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Basic adapter, expect it uses the what is passed by the code, no the resources
+ * @param
+ */
+public class SimpleArrayAdapter extends BaseAdapter {
+ private List mObjects;
+ public SimpleArrayAdapter(List objects) {
+ setObjects(objects);
+ }
+
+ public void setObjects(@Nullable List objects) {
+ if(objects == null){
+ if(mObjects != Collections.emptyList()) {
+ mObjects = Collections.emptyList();
+ notifyDataSetChanged();
+ }
+ } else {
+ if(objects != mObjects){
+ mObjects = objects;
+ notifyDataSetChanged();
+ }
+ }
+ }
+
+ @Override
+ public int getCount() {
+ return mObjects.size();
+ }
+
+ @Override
+ public T getItem(int position) {
+ return mObjects.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @NonNull
+ @Override
+ public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+ if(convertView == null){
+ convertView = LayoutInflater.from(parent.getContext()).inflate(android.R.layout.simple_list_item_1, parent, false);
+ }
+
+ TextView v = (TextView) convertView;
+ v.setText(mObjects.get(position).toString());
+ return v;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java
index d0cd464c2..ae2e66f88 100644
--- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java
+++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java
@@ -77,6 +77,11 @@ public void setSelection(int position){
mProfileAdapter.setViewProfile(this, (String) mProfileAdapter.getItem(position), false);
}
+ /** Reload profiles from the file, forcing the spinner to consider the new data */
+ public void reloadProfiles(){
+ mProfileAdapter.reloadProfiles();
+ }
+
/** Initialize various behaviors */
private void init(){
// Setup various attributes
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JRE17Util.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JRE17Util.java
index f004f1307..1cd0dca35 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JRE17Util.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JRE17Util.java
@@ -55,7 +55,7 @@ public static boolean installNewJreIfNeeded(Activity activity, JMinecraftVersion
if (versionInfo.javaVersion == null || versionInfo.javaVersion.component.equalsIgnoreCase("jre-legacy"))
return true;
- LauncherProfiles.update();
+ LauncherProfiles.load();
MinecraftProfile minecraftProfile = LauncherProfiles.getCurrentProfile();
String selectedRuntime = Tools.getSelectedRuntime(minecraftProfile);
@@ -71,7 +71,7 @@ public static boolean installNewJreIfNeeded(Activity activity, JMinecraftVersion
JRE17Util.checkInternalNewJre(activity.getAssets());
}
minecraftProfile.javaDir = Tools.LAUNCHERPROFILES_RTPREFIX + appropriateRuntime;
- LauncherProfiles.update();
+ LauncherProfiles.load();
} else {
if (versionInfo.javaVersion.majorVersion <= 17) { // there's a chance we have an internal one for this case
if (!JRE17Util.checkInternalNewJre(activity.getAssets())){
@@ -79,7 +79,7 @@ public static boolean installNewJreIfNeeded(Activity activity, JMinecraftVersion
return false;
} else {
minecraftProfile.javaDir = Tools.LAUNCHERPROFILES_RTPREFIX + JRE17Util.NEW_JRE_NAME;
- LauncherProfiles.update();
+ LauncherProfiles.load();
}
} else {
showRuntimeFail(activity, versionInfo);
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java
index 76a05254d..cbe045315 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java
@@ -24,6 +24,7 @@
import com.kdt.mcgui.ProgressLayout;
import com.kdt.mcgui.mcAccountSpinner;
+import net.kdt.pojavlaunch.contextexecutor.ContextExecutor;
import net.kdt.pojavlaunch.fragments.MainMenuFragment;
import net.kdt.pojavlaunch.fragments.MicrosoftLoginFragment;
import net.kdt.pojavlaunch.extra.ExtraConstants;
@@ -31,6 +32,8 @@
import net.kdt.pojavlaunch.extra.ExtraListener;
import net.kdt.pojavlaunch.fragments.SelectAuthFragment;
+import net.kdt.pojavlaunch.modloaders.modpacks.ModloaderInstallTracker;
+import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.IconCacheJanitor;
import net.kdt.pojavlaunch.multirt.MultiRTConfigDialog;
import net.kdt.pojavlaunch.prefs.LauncherPreferences;
import net.kdt.pojavlaunch.prefs.screens.LauncherPreferenceFragment;
@@ -53,6 +56,7 @@ public class LauncherActivity extends BaseActivity {
private ImageButton mSettingsButton, mDeleteAccountButton;
private ProgressLayout mProgressLayout;
private ProgressServiceKeeper mProgressServiceKeeper;
+ private ModloaderInstallTracker mInstallTracker;
/* Allows to switch from one button "type" to another */
private final FragmentManager.FragmentLifecycleCallbacks mFragmentCallbackListener = new FragmentManager.FragmentLifecycleCallbacks() {
@@ -152,6 +156,7 @@ public void onDownloadFailed(Throwable th) {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_pojav_launcher);
+ IconCacheJanitor.runJanitor();
getWindow().setBackgroundDrawable(null);
bindViews();
ProgressKeeper.addTaskCountListener((mProgressServiceKeeper = new ProgressServiceKeeper(this)));
@@ -167,6 +172,8 @@ protected void onCreate(Bundle savedInstanceState) {
new AsyncVersionList().getVersionList(versions -> ExtraCore.setValue(ExtraConstants.RELEASE_TABLE, versions), false);
+ mInstallTracker = new ModloaderInstallTracker(this);
+
mProgressLayout.observe(ProgressLayout.DOWNLOAD_MINECRAFT);
mProgressLayout.observe(ProgressLayout.UNPACK_RUNTIME);
mProgressLayout.observe(ProgressLayout.INSTALL_MODPACK);
@@ -174,6 +181,20 @@ protected void onCreate(Bundle savedInstanceState) {
mProgressLayout.observe(ProgressLayout.DOWNLOAD_VERSION_LIST);
}
+ @Override
+ protected void onResume() {
+ super.onResume();
+ ContextExecutor.setActivity(this);
+ mInstallTracker.attach();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ ContextExecutor.clearActivity();
+ mInstallTracker.detach();
+ }
+
@Override
public boolean setFullscreen() {
return false;
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java
index 3b6d8abff..dfad0b9f1 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java
@@ -322,24 +322,24 @@ private void runCraft(String versionId, JMinecraftVersionList.Version version) t
}
MinecraftAccount minecraftAccount = PojavProfile.getCurrentProfileContent(this, null);
Logger.appendToLog("--------- beginning with launcher debug");
- printLauncherInfo(versionId);
+ printLauncherInfo(versionId, Tools.isValidString(minecraftProfile.javaArgs) ? minecraftProfile.javaArgs : LauncherPreferences.PREF_CUSTOM_JAVA_ARGS);
if (Tools.LOCAL_RENDERER.equals("vulkan_zink")) {
checkVulkanZinkIsSupported();
}
JREUtils.redirectAndPrintJRELog();
- LauncherProfiles.update();
+ LauncherProfiles.load();
int requiredJavaVersion = 8;
if(version.javaVersion != null) requiredJavaVersion = version.javaVersion.majorVersion;
Tools.launchMinecraft(this, minecraftAccount, minecraftProfile, versionId, requiredJavaVersion);
}
- private void printLauncherInfo(String gameVersion) {
+ private void printLauncherInfo(String gameVersion, String javaArguments) {
Logger.appendToLog("Info: Launcher version: " + BuildConfig.VERSION_NAME);
Logger.appendToLog("Info: Architecture: " + Architecture.archAsString(Tools.DEVICE_ARCHITECTURE));
Logger.appendToLog("Info: Device model: " + Build.MANUFACTURER + " " +Build.MODEL);
Logger.appendToLog("Info: API version: " + Build.VERSION.SDK_INT);
Logger.appendToLog("Info: Selected Minecraft version: " + gameVersion);
- Logger.appendToLog("Info: Custom Java arguments: \"" + LauncherPreferences.PREF_CUSTOM_JAVA_ARGS + "\"");
+ Logger.appendToLog("Info: Custom Java arguments: \"" + javaArguments + "\"");
}
private void checkVulkanZinkIsSupported() {
@@ -430,12 +430,12 @@ public void adjustMouseSpeedLive() {
sb.setMax(275);
tmpMouseSpeed = (int) ((LauncherPreferences.PREF_MOUSESPEED*100));
sb.setProgress(tmpMouseSpeed-25);
- tv.setText(getString(R.string.percent_format, tmpGyroSensitivity));
+ tv.setText(getString(R.string.percent_format, tmpMouseSpeed));
sb.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
tmpMouseSpeed = i+25;
- tv.setText(getString(R.string.percent_format, tmpGyroSensitivity));
+ tv.setText(getString(R.string.percent_format, tmpMouseSpeed));
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java
index ee67bca05..09a22a2ba 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java
@@ -18,6 +18,7 @@
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
+import net.kdt.pojavlaunch.contextexecutor.ContextExecutor;
import net.kdt.pojavlaunch.tasks.AsyncAssetManager;
import net.kdt.pojavlaunch.utils.*;
@@ -27,6 +28,7 @@ public class PojavApplication extends Application {
@Override
public void onCreate() {
+ ContextExecutor.setApplication(this);
Thread.setDefaultUncaughtExceptionHandler((thread, th) -> {
boolean storagePermAllowed = (Build.VERSION.SDK_INT < 23 || Build.VERSION.SDK_INT >= 29 ||
ActivityCompat.checkSelfPermission(PojavApplication.this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) && Tools.checkStorageRoot(PojavApplication.this);
@@ -78,8 +80,14 @@ public void onCreate() {
startActivity(ferrorIntent);
}
}
-
- @Override
+
+ @Override
+ public void onTerminate() {
+ super.onTerminate();
+ ContextExecutor.clearApplication();
+ }
+
+ @Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(LocaleUtils.setLocale(base));
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/ShowErrorActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/ShowErrorActivity.java
new file mode 100644
index 000000000..ec64ee7fb
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/ShowErrorActivity.java
@@ -0,0 +1,75 @@
+package net.kdt.pojavlaunch;
+
+import android.app.Activity;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+
+import net.kdt.pojavlaunch.contextexecutor.ContextExecutorTask;
+import net.kdt.pojavlaunch.value.NotificationConstants;
+
+import java.io.Serializable;
+
+public class ShowErrorActivity extends Activity {
+
+ private static final String ERROR_ACTIVITY_REMOTE_TASK = "remoteTask";
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Intent intent = getIntent();
+ if(intent == null) {
+ finish();
+ return;
+ }
+ RemoteErrorTask remoteErrorTask = (RemoteErrorTask) intent.getSerializableExtra(ERROR_ACTIVITY_REMOTE_TASK);
+ if(remoteErrorTask == null) {
+ finish();
+ return;
+ }
+ remoteErrorTask.executeWithActivity(this);
+ }
+
+
+ public static class RemoteErrorTask implements ContextExecutorTask, Serializable {
+ private final Throwable mThrowable;
+ private final String mRolledMsg;
+
+ public RemoteErrorTask(Throwable mThrowable, String mRolledMsg) {
+ this.mThrowable = mThrowable;
+ this.mRolledMsg = mRolledMsg;
+ }
+ @Override
+ public void executeWithActivity(Activity activity) {
+ Tools.showError(activity, mRolledMsg, mThrowable);
+ }
+
+ @Override
+ public void executeWithApplication(Context context) {
+ sendNotification(context, this);
+ }
+ }
+ private static void sendNotification(Context context, RemoteErrorTask remoteErrorTask) {
+
+ Intent showErrorIntent = new Intent(context, ShowErrorActivity.class);
+ showErrorIntent.putExtra(ERROR_ACTIVITY_REMOTE_TASK, remoteErrorTask);
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, NotificationConstants.PENDINGINTENT_CODE_SHOW_ERROR, showErrorIntent,
+ Build.VERSION.SDK_INT >=23 ? PendingIntent.FLAG_IMMUTABLE : 0);
+
+ NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, context.getString(R.string.notif_channel_id))
+ .setContentTitle(context.getString(R.string.notif_error_occured))
+ .setContentText(context.getString(R.string.notif_error_occured_desc))
+ .setSmallIcon(R.drawable.notif_icon)
+ .setContentIntent(pendingIntent);
+ notificationManager.notify(NotificationConstants.NOTIFICATION_ID_SHOW_ERROR, notificationBuilder.build());
+ }
+
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java
index 9a5392ee7..e2f7d711b 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java
@@ -33,6 +33,7 @@
import android.view.WindowManager;
import android.webkit.MimeTypeMap;
import android.widget.EditText;
+import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -45,6 +46,7 @@
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
+import net.kdt.pojavlaunch.contextexecutor.ContextExecutor;
import net.kdt.pojavlaunch.multirt.MultiRTUtils;
import net.kdt.pojavlaunch.multirt.Runtime;
import net.kdt.pojavlaunch.plugins.FFmpegPlugin;
@@ -55,6 +57,7 @@
import net.kdt.pojavlaunch.utils.OldVersionsUtils;
import net.kdt.pojavlaunch.value.DependentLibrary;
import net.kdt.pojavlaunch.value.MinecraftAccount;
+import net.kdt.pojavlaunch.value.MinecraftLibraryArtifact;
import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
@@ -79,6 +82,7 @@
@SuppressWarnings("IOStreamConstructor")
public final class Tools {
+ public static final float BYTE_TO_MB = 1024 * 1024;
public static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
public static String APP_NAME = "null";
@@ -173,7 +177,7 @@ public static void launchMinecraft(final Activity activity, MinecraftAccount min
}
Runtime runtime = MultiRTUtils.forceReread(Tools.pickRuntime(minecraftProfile, versionJavaRequirement));
JMinecraftVersionList.Version versionInfo = Tools.getVersionInfo(versionId);
- LauncherProfiles.update();
+ LauncherProfiles.load();
File gamedir = Tools.getGameDirPath(minecraftProfile);
@@ -592,6 +596,23 @@ private static void showError(final Context ctx, final int titleId, final String
}
}
+ public static void showErrorRemote(Throwable e) {
+ showErrorRemote(null, e);
+ }
+ public static void showErrorRemote(Context context, int rolledMessage, Throwable e) {
+ showErrorRemote(context.getString(rolledMessage), e);
+ }
+ public static void showErrorRemote(String rolledMessage, Throwable e) {
+ // I WILL embrace layer violations because Android's concept of layers is STUPID
+ // We live in the same process anyway, why make it any more harder with this needless
+ // abstraction?
+
+ // Add your Context-related rage here
+ ContextExecutor.execute(new ShowErrorActivity.RemoteErrorTask(e, rolledMessage));
+ }
+
+
+
public static void dialogOnUiThread(final Activity activity, final CharSequence title, final CharSequence message) {
activity.runOnUiThread(()->dialog(activity, title, message));
}
@@ -626,8 +647,9 @@ private static void preProcessLibraries(DependentLibrary[] libraries) {
if (libItem.name.startsWith("net.java.dev.jna:jna:")) {
// Special handling for LabyMod 1.8.9, Forge 1.12.2(?) and oshi
// we have libjnidispatch 5.13.0 in jniLibs directory
- if (Integer.parseInt(version[0]) >= 5 && Integer.parseInt(version[1]) >= 13) return;
+ if (Integer.parseInt(version[0]) >= 5 && Integer.parseInt(version[1]) >= 13) continue;
Log.d(APP_NAME, "Library " + libItem.name + " has been changed to version 5.13.0");
+ createLibraryInfo(libItem);
libItem.name = "net.java.dev.jna:jna:5.13.0";
libItem.downloads.artifact.path = "net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar";
libItem.downloads.artifact.sha1 = "1200e7ebeedbe0d10062093f32925a912020e747";
@@ -636,16 +658,34 @@ private static void preProcessLibraries(DependentLibrary[] libraries) {
//if (Integer.parseInt(version[0]) >= 6 && Integer.parseInt(version[1]) >= 3) return;
// FIXME: ensure compatibility
- if (Integer.parseInt(version[0]) != 6 || Integer.parseInt(version[1]) != 2) return;
+ if (Integer.parseInt(version[0]) != 6 || Integer.parseInt(version[1]) != 2) continue;
Log.d(APP_NAME, "Library " + libItem.name + " has been changed to version 6.3.0");
+ createLibraryInfo(libItem);
libItem.name = "com.github.oshi:oshi-core:6.3.0";
libItem.downloads.artifact.path = "com/github/oshi/oshi-core/6.3.0/oshi-core-6.3.0.jar";
libItem.downloads.artifact.sha1 = "9e98cf55be371cafdb9c70c35d04ec2a8c2b42ac";
libItem.downloads.artifact.url = "https://repo1.maven.org/maven2/com/github/oshi/oshi-core/6.3.0/oshi-core-6.3.0.jar";
+ } else if (libItem.name.startsWith("org.ow2.asm:asm-all:")) {
+ // Early versions of the ASM library get repalced with 5.0.4 because Pojav's LWJGL is compiled for
+ // Java 8, which is not supported by old ASM versions. Mod loaders like Forge, which depend on this
+ // library, often include lwjgl in their class transformations, which causes errors with old ASM versions.
+ if(Integer.parseInt(version[0]) >= 5) continue;
+ Log.d(APP_NAME, "Library " + libItem.name + " has been changed to version 5.0.4");
+ createLibraryInfo(libItem);
+ libItem.name = "org.ow2.asm:asm-all:5.0.4";
+ libItem.url = null;
+ libItem.downloads.artifact.path = "org/ow2/asm/asm-all/5.0.4/asm-all-5.0.4.jar";
+ libItem.downloads.artifact.sha1 = "e6244859997b3d4237a552669279780876228909";
+ libItem.downloads.artifact.url = "https://repo1.maven.org/maven2/org/ow2/asm/asm-all/5.0.4/asm-all-5.0.4.jar";
}
}
}
+ private static void createLibraryInfo(DependentLibrary library) {
+ if(library.downloads == null || library.downloads.artifact == null)
+ library.downloads = new DependentLibrary.LibraryDownloads(new MinecraftLibraryArtifact());
+ }
+
public static String[] generateLibClasspath(JMinecraftVersionList.Version info) {
List libDir = new ArrayList<>();
for (DependentLibrary libItem: info.libraries) {
@@ -1028,4 +1068,12 @@ public static void shareLog(Context context){
Intent sendIntent = Intent.createChooser(shareIntent, "latestlog.txt");
context.startActivity(sendIntent);
}
+
+ /** Mesure the textview height, given its current parameters */
+ public static int mesureTextviewHeight(TextView t) {
+ int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(t.getWidth(), View.MeasureSpec.AT_MOST);
+ int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
+ t.measure(widthMeasureSpec, heightMeasureSpec);
+ return t.getMeasuredHeight();
+ }
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contextexecutor/ContextExecutor.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contextexecutor/ContextExecutor.java
new file mode 100644
index 000000000..7b17f5cc0
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contextexecutor/ContextExecutor.java
@@ -0,0 +1,75 @@
+package net.kdt.pojavlaunch.contextexecutor;
+
+import android.app.Activity;
+import android.app.Application;
+
+import net.kdt.pojavlaunch.Tools;
+
+import java.lang.ref.WeakReference;
+
+public class ContextExecutor {
+ private static WeakReference sApplication;
+ private static WeakReference sActivity;
+
+
+ /**
+ * Schedules a ContextExecutorTask to be executed. For more info on tasks, please read
+ * ContextExecutorTask.java
+ * @param contextExecutorTask the task to be executed
+ */
+ public static void execute(ContextExecutorTask contextExecutorTask) {
+ Tools.runOnUiThread(()->executeOnUiThread(contextExecutorTask));
+ }
+
+ private static void executeOnUiThread(ContextExecutorTask contextExecutorTask) {
+ Activity activity = getWeakReference(sActivity);
+ if(activity != null) {
+ contextExecutorTask.executeWithActivity(activity);
+ return;
+ }
+ Application application = getWeakReference(sApplication);
+ if(application != null) {
+ contextExecutorTask.executeWithApplication(application);
+ }else {
+ throw new RuntimeException("ContextExecutor.execute() called before Application.onCreate!");
+ }
+ }
+
+ /**
+ * Set the Activity that this ContextExecutor will use for executing tasks
+ * @param activity the activity to be used
+ */
+ public static void setActivity(Activity activity) {
+ sActivity = new WeakReference<>(activity);
+ }
+
+ /**
+ * Clear the Activity previously set, so thet ContextExecutor won't use it to execute tasks.
+ */
+ public static void clearActivity() {
+ if(sActivity != null)
+ sActivity.clear();
+ }
+
+ /**
+ * Set the Application that will be used to execute tasks if the Activity won't be available.
+ * @param application the application to use as the fallback
+ */
+ public static void setApplication(Application application) {
+ sApplication = new WeakReference<>(application);
+ }
+
+ /**
+ * Clear the Application previously set, so that ContextExecutor will notify the user of a critical error
+ * that is executing code after the application is ended by the system.
+ */
+ public static void clearApplication() {
+ if(sApplication != null)
+ sApplication.clear();
+ }
+
+ private static T getWeakReference(WeakReference weakReference) {
+ if(weakReference == null) return null;
+ return weakReference.get();
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contextexecutor/ContextExecutorTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contextexecutor/ContextExecutorTask.java
new file mode 100644
index 000000000..9d8b1d3c3
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contextexecutor/ContextExecutorTask.java
@@ -0,0 +1,25 @@
+package net.kdt.pojavlaunch.contextexecutor;
+
+import android.app.Activity;
+import android.content.Context;
+
+/**
+ * A ContextExecutorTask is a task that can dynamically change its behaviour, based on the context
+ * used for its execution. This can be used to implement for ex. error/finish notifications from
+ * background threads that may live with the Service after the activity that started them died.
+ */
+public interface ContextExecutorTask {
+ /**
+ * ContextExecutor will execute this function first if a foreground Activity that was attached to the
+ * ContextExecutor is available.
+ * @param activity the activity
+ */
+ void executeWithActivity(Activity activity);
+
+ /**
+ * ContextExecutor will execute this function if a foreground Activity is not available, but the app
+ * is still running.
+ * @param context the application context
+ */
+ void executeWithApplication(Context context);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/FabricInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/FabricInstallFragment.java
index 4626a1df9..582ad4835 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/FabricInstallFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/FabricInstallFragment.java
@@ -42,7 +42,6 @@ public class FabricInstallFragment extends Fragment implements AdapterView.OnIte
private String mSelectedGameVersion;
private boolean mSelectedSnapshot;
private ProgressBar mProgressBar;
- private File mDestinationDir;
private Button mStartButton;
private View mRetryView;
public FabricInstallFragment() {
@@ -52,7 +51,6 @@ public FabricInstallFragment() {
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
- this.mDestinationDir = new File(Tools.DIR_CACHE, "fabric-installer");
}
@Override
@@ -88,7 +86,7 @@ private void onClickStart(View v) {
return;
}
sTaskProxy = new ModloaderListenerProxy();
- FabricDownloadTask fabricDownloadTask = new FabricDownloadTask(sTaskProxy, mDestinationDir);
+ FabricDownloadTask fabricDownloadTask = new FabricDownloadTask(sTaskProxy);
sTaskProxy.attachListener(this);
mStartButton.setEnabled(false);
new Thread(fabricDownloadTask).start();
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgeInstallFragment.java
index fe609697f..9455a23b4 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgeInstallFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgeInstallFragment.java
@@ -59,7 +59,7 @@ public ExpandableListAdapter createAdapter(List versionList, LayoutInfla
@Override
public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) {
- return new ForgeDownloadTask(listenerProxy, (String) selectedVersion, new File(Tools.DIR_CACHE, "forge-installer.jar"));
+ return new ForgeDownloadTask(listenerProxy, (String) selectedVersion);
}
@Override
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java
index 29e5b4d33..701a7a76b 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java
@@ -13,16 +13,21 @@
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
+import com.kdt.mcgui.mcVersionSpinner;
+
import net.kdt.pojavlaunch.CustomControlsActivity;
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.extra.ExtraConstants;
import net.kdt.pojavlaunch.extra.ExtraCore;
import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
+import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
public class MainMenuFragment extends Fragment {
public static final String TAG = "MainMenuFragment";
+ private mcVersionSpinner mVersionSpinner;
+
public MainMenuFragment(){
super(R.layout.fragment_launcher);
}
@@ -36,6 +41,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
ImageButton mEditProfileButton = view.findViewById(R.id.edit_profile_button);
Button mPlayButton = view.findViewById(R.id.play_button);
+ mVersionSpinner = view.findViewById(R.id.mc_version_spinner);
mNewsButton.setOnClickListener(v -> Tools.openURL(requireActivity(), Tools.URL_HOME));
mCustomControlButton.setOnClickListener(v -> startActivity(new Intent(requireContext(), CustomControlsActivity.class)));
@@ -51,10 +57,17 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
mShareLogsButton.setOnClickListener((v) -> shareLog(requireContext()));
mNewsButton.setOnLongClickListener((v)->{
- Tools.swapFragment(requireActivity(), FabricInstallFragment.class, FabricInstallFragment.TAG, true, null);
+ Tools.swapFragment(requireActivity(), SearchModFragment.class, SearchModFragment.TAG, true, null);
return true;
});
}
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mVersionSpinner.reloadProfiles();
+ }
+
private void runInstallerWithConfirmation(boolean isCustomArgs) {
if (ProgressKeeper.getTaskCount() == 0)
Tools.installMod(requireActivity(), isCustomArgs);
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java
index a37704618..abe7c0b39 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OptiFineInstallFragment.java
@@ -51,8 +51,7 @@ public ExpandableListAdapter createAdapter(OptiFineUtils.OptiFineVersions versio
@Override
public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) {
- return new OptiFineDownloadTask((OptiFineUtils.OptiFineVersion) selectedVersion,
- new File(Tools.DIR_CACHE, "optifine-installer.jar"), listenerProxy);
+ return new OptiFineDownloadTask((OptiFineUtils.OptiFineVersion) selectedVersion, listenerProxy);
}
@Override
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java
index fc3a334a9..63f6c8365 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java
@@ -86,7 +86,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
mDeleteButton.setOnClickListener(v -> {
if(LauncherProfiles.mainProfileJson.profiles.size() > 1){
LauncherProfiles.mainProfileJson.profiles.remove(mProfileKey);
- LauncherProfiles.update();
+ LauncherProfiles.write();
ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, DELETED_PROFILE);
}
@@ -211,7 +211,7 @@ private void save(){
LauncherProfiles.mainProfileJson.profiles.put(mProfileKey, mTempProfile);
- LauncherProfiles.update();
+ LauncherProfiles.write();
ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, mProfileKey);
}
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java
index 2c29ffff5..6fe618442 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileTypeSelectFragment.java
@@ -31,5 +31,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
Tools.swapFragment(requireActivity(), FabricInstallFragment.class, FabricInstallFragment.TAG, false, null));
view.findViewById(R.id.modded_profile_forge).setOnClickListener((v)->
Tools.swapFragment(requireActivity(), ForgeInstallFragment.class, ForgeInstallFragment.TAG, false, null));
+ view.findViewById(R.id.modded_profile_modpack).setOnClickListener((v)->
+ Tools.swapFragment(requireActivity(), SearchModFragment.class, SearchModFragment.TAG, false, null));
}
}
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
new file mode 100644
index 000000000..ace5d14af
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java
@@ -0,0 +1,165 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.math.MathUtils;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.modloaders.modpacks.ModItemAdapter;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters;
+import net.kdt.pojavlaunch.profiles.VersionSelectorDialog;
+import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
+
+public class SearchModFragment extends Fragment implements ModItemAdapter.SearchResultCallback {
+
+ public static final String TAG = "SearchModFragment";
+ private View mOverlay;
+ private float mOverlayTopCache; // Padding cache reduce resource lookup
+
+ private final RecyclerView.OnScrollListener mOverlayPositionListener = new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
+ mOverlay.setY(MathUtils.clamp(mOverlay.getY() - dy, -mOverlay.getHeight(), mOverlayTopCache));
+ }
+ };
+
+ private EditText mSearchEditText;
+ private ImageButton mFilterButton;
+ private RecyclerView mRecyclerview;
+ private ModItemAdapter mModItemAdapter;
+ private ProgressBar mSearchProgressBar;
+ private TextView mStatusTextView;
+ private ColorStateList mDefaultTextColor;
+
+ private ModpackApi modpackApi;
+
+ private final SearchFilters mSearchFilters;
+
+ public SearchModFragment(){
+ super(R.layout.fragment_mod_search);
+ mSearchFilters = new SearchFilters();
+ mSearchFilters.isModpack = true;
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ modpackApi = new CommonApi(context.getString(R.string.curseforge_api_key));
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ // You can only access resources after attaching to current context
+ mModItemAdapter = new ModItemAdapter(getResources(), modpackApi, this);
+ ProgressKeeper.addTaskCountListener(mModItemAdapter);
+ mOverlayTopCache = getResources().getDimension(R.dimen.fragment_padding_medium);
+
+ mOverlay = view.findViewById(R.id.search_mod_overlay);
+ mSearchEditText = view.findViewById(R.id.search_mod_edittext);
+ mSearchProgressBar = view.findViewById(R.id.search_mod_progressbar);
+ mRecyclerview = view.findViewById(R.id.search_mod_list);
+ mStatusTextView = view.findViewById(R.id.search_mod_status_text);
+ mFilterButton = view.findViewById(R.id.search_mod_filter);
+
+ mDefaultTextColor = mStatusTextView.getTextColors();
+
+ mRecyclerview.setLayoutManager(new LinearLayoutManager(getContext()));
+ mRecyclerview.setAdapter(mModItemAdapter);
+
+ mRecyclerview.addOnScrollListener(mOverlayPositionListener);
+
+ mSearchEditText.setOnEditorActionListener((v, actionId, event) -> {
+ mSearchProgressBar.setVisibility(View.VISIBLE);
+ mSearchFilters.name = mSearchEditText.getText().toString();
+ mModItemAdapter.performSearchQuery(mSearchFilters);
+ return true;
+ });
+
+ mOverlay.post(()->{
+ int overlayHeight = mOverlay.getHeight();
+ mRecyclerview.setPadding(mRecyclerview.getPaddingLeft(),
+ mRecyclerview.getPaddingTop() + overlayHeight,
+ mRecyclerview.getPaddingRight(),
+ mRecyclerview.getPaddingBottom());
+ });
+ mFilterButton.setOnClickListener(v -> displayFilterDialog());
+ }
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ ProgressKeeper.removeTaskCountListener(mModItemAdapter);
+ mRecyclerview.removeOnScrollListener(mOverlayPositionListener);
+ }
+
+ @Override
+ public void onSearchFinished() {
+ mSearchProgressBar.setVisibility(View.GONE);
+ mStatusTextView.setVisibility(View.GONE);
+ }
+
+ @Override
+ public void onSearchError(int error) {
+ mSearchProgressBar.setVisibility(View.GONE);
+ mStatusTextView.setVisibility(View.VISIBLE);
+ switch (error) {
+ case ERROR_INTERNAL:
+ mStatusTextView.setTextColor(Color.RED);
+ mStatusTextView.setText(R.string.search_modpack_error);
+ break;
+ case ERROR_NO_RESULTS:
+ mStatusTextView.setTextColor(mDefaultTextColor);
+ mStatusTextView.setText(R.string.search_modpack_no_result);
+ break;
+ }
+ }
+
+ private void displayFilterDialog() {
+ AlertDialog dialog = new AlertDialog.Builder(requireContext())
+ .setView(R.layout.dialog_mod_filters)
+ .create();
+
+ // setup the view behavior
+ dialog.setOnShowListener(dialogInterface -> {
+ TextView mSelectedVersion = dialog.findViewById(R.id.search_mod_selected_mc_version_textview);
+ Button mSelectVersionButton = dialog.findViewById(R.id.search_mod_mc_version_button);
+ Button mApplyButton = dialog.findViewById(R.id.search_mod_apply_filters);
+
+ assert mSelectVersionButton != null;
+ assert mSelectedVersion != null;
+ assert mApplyButton != null;
+
+ // Setup the expendable list behavior
+ mSelectVersionButton.setOnClickListener(v -> VersionSelectorDialog.open(v.getContext(), true, (id, snapshot)-> mSelectedVersion.setText(id)));
+
+ // Apply visually all the current settings
+ mSelectedVersion.setText(mSearchFilters.mcVersion);
+
+ // Apply the new settings
+ mApplyButton.setOnClickListener(v -> {
+ mSearchFilters.mcVersion = mSelectedVersion.getText().toString();
+ dialogInterface.dismiss();
+ });
+ });
+
+
+ dialog.show();
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/FabricDownloadTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/FabricDownloadTask.java
index 03ea723ad..d0a5cd876 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/FabricDownloadTask.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/FabricDownloadTask.java
@@ -15,9 +15,9 @@ public class FabricDownloadTask implements Runnable, Tools.DownloaderFeedback{
private final File mDestinationFile;
private final ModloaderDownloadListener mModloaderDownloadListener;
- public FabricDownloadTask(ModloaderDownloadListener modloaderDownloadListener, File mDestinationDir) {
+ public FabricDownloadTask(ModloaderDownloadListener modloaderDownloadListener) {
this.mModloaderDownloadListener = modloaderDownloadListener;
- this.mDestinationDir = mDestinationDir;
+ this.mDestinationDir = new File(Tools.DIR_CACHE, "fabric-installer");
this.mDestinationFile = new File(mDestinationDir, "fabric-installer.jar");
}
@@ -29,7 +29,7 @@ public void run() {
}catch (IOException e) {
mModloaderDownloadListener.onDownloadError(e);
}
- ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, -1, -1);
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
}
private boolean runCatching() throws IOException {
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeDownloadTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeDownloadTask.java
index 60dcd9b2d..81d7f1d1a 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeDownloadTask.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeDownloadTask.java
@@ -10,39 +10,79 @@
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
+import java.util.List;
public class ForgeDownloadTask implements Runnable, Tools.DownloaderFeedback {
- private final String mForgeUrl;
- private final String mForgeVersion;
- private final File mDestinationFile;
+ private String mDownloadUrl;
+ private String mFullVersion;
+ private String mLoaderVersion;
+ private String mGameVersion;
private final ModloaderDownloadListener mListener;
- public ForgeDownloadTask(ModloaderDownloadListener listener, String forgeVersion, File destinationFile) {
+ public ForgeDownloadTask(ModloaderDownloadListener listener, String forgeVersion) {
this.mListener = listener;
- this.mForgeUrl = ForgeUtils.getInstallerUrl(forgeVersion);
- this.mForgeVersion = forgeVersion;
- this.mDestinationFile = destinationFile;
+ this.mDownloadUrl = ForgeUtils.getInstallerUrl(forgeVersion);
+ this.mFullVersion = forgeVersion;
+ }
+
+ public ForgeDownloadTask(ModloaderDownloadListener listener, String gameVersion, String loaderVersion) {
+ this.mListener = listener;
+ this.mLoaderVersion = loaderVersion;
+ this.mGameVersion = gameVersion;
}
@Override
public void run() {
- ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.forge_dl_progress, mForgeVersion);
+ if(determineDownloadUrl()) {
+ downloadForge();
+ }
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
+ }
+
+ @Override
+ public void updateProgress(int curr, int max) {
+ int progress100 = (int)(((float)curr / (float)max)*100f);
+ ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, progress100, R.string.forge_dl_progress, mFullVersion);
+ }
+
+ private void downloadForge() {
+ ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.forge_dl_progress, mFullVersion);
try {
+ File destinationFile = new File(Tools.DIR_CACHE, "forge-installer.jar");
byte[] buffer = new byte[8192];
- DownloadUtils.downloadFileMonitored(mForgeUrl, mDestinationFile, buffer, this);
- mListener.onDownloadFinished(mDestinationFile);
- }catch (IOException e) {
- if(e instanceof FileNotFoundException) {
+ DownloadUtils.downloadFileMonitored(mDownloadUrl, destinationFile, buffer, this);
+ mListener.onDownloadFinished(destinationFile);
+ }catch (FileNotFoundException e) {
+ mListener.onDataNotAvailable();
+ } catch (IOException e) {
+ mListener.onDownloadError(e);
+ }
+ }
+
+ public boolean determineDownloadUrl() {
+ if(mDownloadUrl != null && mFullVersion != null) return true;
+ ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.forge_dl_searching);
+ try {
+ if(!findVersion()) {
mListener.onDataNotAvailable();
- }else{
- mListener.onDownloadError(e);
+ return false;
}
+ }catch (IOException e) {
+ mListener.onDownloadError(e);
+ return false;
}
- ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, -1, -1);
+ return true;
}
- @Override
- public void updateProgress(int curr, int max) {
- int progress100 = (int)(((float)curr / (float)max)*100f);
- ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, progress100, R.string.forge_dl_progress, mForgeVersion);
+ public boolean findVersion() throws IOException {
+ List forgeVersions = ForgeUtils.downloadForgeVersions();
+ if(forgeVersions == null) return false;
+ String versionStart = mGameVersion+"-"+mLoaderVersion;
+ for(String versionName : forgeVersions) {
+ if(!versionName.startsWith(versionStart)) continue;
+ mFullVersion = versionName;
+ mDownloadUrl = ForgeUtils.getInstallerUrl(mFullVersion);
+ return true;
+ }
+ return false;
}
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeUtils.java
index 31420de90..5925d9b4d 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeUtils.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgeUtils.java
@@ -59,4 +59,10 @@ public static void addAutoInstallArgs(Intent intent, File modInstallerJar, boole
" -jar "+modInstallerJar.getAbsolutePath());
intent.putExtra("skipDetectMod", true);
}
+ public static void addAutoInstallArgs(Intent intent, File modInstallerJar, String modpackFixupId) {
+ intent.putExtra("javaArgs", "-javaagent:"+ Tools.DIR_DATA+"/forge_installer/forge_installer.jar"
+ + "=\"" + modpackFixupId +"\"" +
+ " -jar "+modInstallerJar.getAbsolutePath());
+ intent.putExtra("skipDetectMod", true);
+ }
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java
index c5278340d..d4fcfab68 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/OptiFineDownloadTask.java
@@ -22,9 +22,9 @@ public class OptiFineDownloadTask implements Runnable, Tools.DownloaderFeedback,
private final Object mMinecraftDownloadLock = new Object();
private Throwable mDownloaderThrowable;
- public OptiFineDownloadTask(OptiFineUtils.OptiFineVersion mOptiFineVersion, File mDestinationFile, ModloaderDownloadListener mListener) {
+ public OptiFineDownloadTask(OptiFineUtils.OptiFineVersion mOptiFineVersion, ModloaderDownloadListener mListener) {
this.mOptiFineVersion = mOptiFineVersion;
- this.mDestinationFile = mDestinationFile;
+ this.mDestinationFile = new File(Tools.DIR_CACHE, "optifine-installer.jar");
this.mListener = mListener;
}
@@ -36,7 +36,7 @@ public void run() {
}catch (IOException e) {
mListener.onDownloadError(e);
}
- ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, -1, -1);
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
}
public boolean runCatching() throws IOException {
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
new file mode 100644
index 000000000..5eab19ab0
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java
@@ -0,0 +1,409 @@
+package net.kdt.pojavlaunch.modloaders.modpacks;
+
+import android.annotation.SuppressLint;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStub;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.core.graphics.drawable.RoundedBitmapDrawable;
+import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.kdt.SimpleArrayAdapter;
+
+import net.kdt.pojavlaunch.PojavApplication;
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi;
+import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.ImageReceiver;
+import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.ModIconCache;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchResult;
+import net.kdt.pojavlaunch.progresskeeper.TaskCountListener;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.Future;
+
+public class ModItemAdapter extends RecyclerView.Adapter implements TaskCountListener {
+ private static final ModItem[] MOD_ITEMS_EMPTY = new ModItem[0];
+ private static final int VIEW_TYPE_MOD_ITEM = 0;
+ private static final int VIEW_TYPE_LOADING = 1;
+
+ /* Used when versions haven't loaded yet, default text to reduce layout shifting */
+ private final SimpleArrayAdapter mLoadingAdapter = new SimpleArrayAdapter<>(Collections.singletonList("Loading"));
+ /* This my seem horribly inefficient but it is in fact the most efficient way without effectively writing a weak collection from scratch */
+ private final Set mViewHolderSet = Collections.newSetFromMap(new WeakHashMap<>());
+ private final ModIconCache mIconCache = new ModIconCache();
+ private final SearchResultCallback mSearchResultCallback;
+ private ModItem[] mModItems;
+ private final ModpackApi mModpackApi;
+
+ /* Cache for ever so slightly rounding the image for the corner not to stick out of the layout */
+ private final float mCornerDimensionCache;
+
+ private Future> mTaskInProgress;
+ private SearchFilters mSearchFilters;
+ private SearchResult mCurrentResult;
+ private boolean mLastPage;
+ private boolean mTasksRunning;
+
+
+ public ModItemAdapter(Resources resources, ModpackApi api, SearchResultCallback callback) {
+ mCornerDimensionCache = resources.getDimension(R.dimen._1sdp) / 250;
+ mModpackApi = api;
+ mModItems = new ModItem[]{};
+ mSearchResultCallback = callback;
+ }
+
+ public void performSearchQuery(SearchFilters searchFilters) {
+ if(mTaskInProgress != null) {
+ mTaskInProgress.cancel(true);
+ mTaskInProgress = null;
+ }
+ this.mSearchFilters = searchFilters;
+ this.mLastPage = false;
+ mTaskInProgress = new SelfReferencingFuture(new SearchApiTask(mSearchFilters, null))
+ .startOnExecutor(PojavApplication.sExecutorService);
+ }
+
+ @NonNull
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
+ LayoutInflater layoutInflater = LayoutInflater.from(viewGroup.getContext());
+ View view;
+ switch (viewType) {
+ case VIEW_TYPE_MOD_ITEM:
+ // Create a new view, which defines the UI of the list item
+ view = layoutInflater.inflate(R.layout.view_mod, viewGroup, false);
+ return new ViewHolder(view);
+ case VIEW_TYPE_LOADING:
+ // Create a new view, which is actually just the progress bar
+ view = layoutInflater.inflate(R.layout.view_loading, viewGroup, false);
+ return new LoadingViewHolder(view);
+ default:
+ throw new RuntimeException("Unimplemented view type!");
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
+ switch (getItemViewType(position)) {
+ case VIEW_TYPE_MOD_ITEM:
+ ((ModItemAdapter.ViewHolder)holder).setStateLimited(mModItems[position]);
+ break;
+ case VIEW_TYPE_LOADING:
+ loadMoreResults();
+ break;
+ default:
+ throw new RuntimeException("Unimplemented view type!");
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ if(mLastPage || mModItems.length == 0) return mModItems.length;
+ return mModItems.length+1;
+ }
+
+ private void loadMoreResults() {
+ if(mTaskInProgress != null) return;
+ mTaskInProgress = new SelfReferencingFuture(new SearchApiTask(mSearchFilters, mCurrentResult))
+ .startOnExecutor(PojavApplication.sExecutorService);
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ if(position < mModItems.length) return VIEW_TYPE_MOD_ITEM;
+ return VIEW_TYPE_LOADING;
+ }
+
+ @Override
+ public void onUpdateTaskCount(int taskCount) {
+ Tools.runOnUiThread(()->{
+ mTasksRunning = taskCount != 0;
+ for(ViewHolder viewHolder : mViewHolderSet) {
+ viewHolder.updateInstallButtonState();
+ }
+ });
+ }
+
+
+ /**
+ * Basic viewholder with expension capabilities
+ */
+ public class ViewHolder extends RecyclerView.ViewHolder {
+
+ private ModDetail mModDetail = null;
+ private ModItem mModItem = null;
+ private final TextView mTitle, mDescription;
+ private final ImageView mIconView, mSourceView;
+ private View mExtendedLayout;
+ private Spinner mExtendedSpinner;
+ private Button mExtendedButton;
+ private TextView mExtendedErrorTextView;
+ private Future> mExtensionFuture;
+ private Bitmap mThumbnailBitmap;
+ private ImageReceiver mImageReceiver;
+ private boolean mInstallEnabled;
+
+ /* Used to display available versions of the mod(pack) */
+ private final SimpleArrayAdapter mVersionAdapter = new SimpleArrayAdapter<>(null);
+
+ public ViewHolder(View view) {
+ super(view);
+ mViewHolderSet.add(this);
+ view.setOnClickListener(v -> {
+ if(!hasExtended()){
+ // Inflate the ViewStub
+ mExtendedLayout = ((ViewStub)v.findViewById(R.id.mod_limited_state_stub)).inflate();
+ mExtendedButton = mExtendedLayout.findViewById(R.id.mod_extended_select_version_button);
+ mExtendedSpinner = mExtendedLayout.findViewById(R.id.mod_extended_version_spinner);
+ mExtendedErrorTextView = mExtendedLayout.findViewById(R.id.mod_extended_error_textview);
+
+ mExtendedButton.setOnClickListener(v1 -> mModpackApi.handleInstallation(
+ mExtendedButton.getContext().getApplicationContext(),
+ mModDetail,
+ mExtendedSpinner.getSelectedItemPosition()));
+ mExtendedSpinner.setAdapter(mLoadingAdapter);
+ } else {
+ if(isExtended()) closeDetailedView();
+ else openDetailedView();
+ }
+
+ 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(() -> {
+ /*
+ * 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);
+ }
+ });
+
+ // Define click listener for the ViewHolder's View
+ mTitle = view.findViewById(R.id.mod_title_textview);
+ mDescription = view.findViewById(R.id.mod_body_textview);
+ mIconView = view.findViewById(R.id.mod_thumbnail_imageview);
+ mSourceView = view.findViewById(R.id.mod_source_imageview);
+ }
+
+ /** Display basic info about the moditem */
+ public void setStateLimited(ModItem item) {
+ mModDetail = null;
+ if(mThumbnailBitmap != null) {
+ mIconView.setImageBitmap(null);
+ mThumbnailBitmap.recycle();
+ }
+ if(mImageReceiver != null) {
+ 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;
+ // here the previous reference to the image receiver will disappear
+ mImageReceiver = bm->{
+ mImageReceiver = null;
+ mThumbnailBitmap = bm;
+ RoundedBitmapDrawable drawable = RoundedBitmapDrawableFactory.create(mIconView.getResources(), bm);
+ drawable.setCornerRadius(mCornerDimensionCache * bm.getHeight());
+ mIconView.setImageDrawable(drawable);
+ };
+ mIconCache.getImage(mImageReceiver, mModItem.getIconCacheTag(), mModItem.imageUrl);
+ mSourceView.setImageResource(getSourceDrawable(item.apiSource));
+ mTitle.setText(item.title);
+ mDescription.setText(item.description);
+
+ if(hasExtended()){
+ closeDetailedView();
+ }
+ }
+
+ /** Display extended info/interaction about a modpack */
+ private void setStateDetailed(ModDetail detailedItem) {
+ if(detailedItem != null) {
+ setInstallEnabled(true);
+ mExtendedErrorTextView.setVisibility(View.GONE);
+ mVersionAdapter.setObjects(Arrays.asList(detailedItem.versionNames));
+ mExtendedSpinner.setAdapter(mVersionAdapter);
+ } else {
+ closeDetailedView();
+ setInstallEnabled(false);
+ mExtendedErrorTextView.setVisibility(View.VISIBLE);
+ mExtendedSpinner.setAdapter(null);
+ mVersionAdapter.setObjects(null);
+ }
+ }
+
+ private void openDetailedView() {
+ mExtendedLayout.setVisibility(View.VISIBLE);
+ mDescription.setMaxLines(99);
+
+ // We need to align to the longer section
+ int futureBottom = mDescription.getBottom() + Tools.mesureTextviewHeight(mDescription) - mDescription.getHeight();
+ ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) mExtendedLayout.getLayoutParams();
+ params.topToBottom = futureBottom > mIconView.getBottom() ? R.id.mod_body_textview : R.id.mod_thumbnail_imageview;
+ mExtendedLayout.setLayoutParams(params);
+ }
+
+ private void closeDetailedView(){
+ mExtendedLayout.setVisibility(View.GONE);
+ mDescription.setMaxLines(3);
+ }
+
+ private void setDetailedStateDefault() {
+ setInstallEnabled(false);
+ mExtendedSpinner.setAdapter(mLoadingAdapter);
+ mExtendedErrorTextView.setVisibility(View.GONE);
+ openDetailedView();
+ }
+
+ private boolean hasExtended(){
+ return mExtendedLayout != null;
+ }
+
+ private boolean isExtended(){
+ return hasExtended() && mExtendedLayout.getVisibility() == View.VISIBLE;
+ }
+
+ private int getSourceDrawable(int apiSource) {
+ switch (apiSource) {
+ case Constants.SOURCE_CURSEFORGE:
+ return R.drawable.ic_curseforge;
+ case Constants.SOURCE_MODRINTH:
+ return R.drawable.ic_modrinth;
+ default:
+ throw new RuntimeException("Unknown API source");
+ }
+ }
+
+ private void setInstallEnabled(boolean enabled) {
+ mInstallEnabled = enabled;
+ updateInstallButtonState();
+ }
+
+ private void updateInstallButtonState() {
+ if(mExtendedButton != null)
+ mExtendedButton.setEnabled(mInstallEnabled && !mTasksRunning);
+ }
+ }
+
+ /**
+ * The view holder used to hold the progress bar at the end of the list
+ */
+ private static class LoadingViewHolder extends RecyclerView.ViewHolder {
+ public LoadingViewHolder(View view) {
+ super(view);
+ }
+ }
+
+ private class SearchApiTask implements SelfReferencingFuture.FutureInterface {
+ private final SearchFilters mSearchFilters;
+ private final SearchResult mPreviousResult;
+
+ private SearchApiTask(SearchFilters searchFilters, SearchResult previousResult) {
+ this.mSearchFilters = searchFilters;
+ this.mPreviousResult = previousResult;
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ @Override
+ public void run(Future> myFuture) {
+ SearchResult result = mModpackApi.searchMod(mSearchFilters, mPreviousResult);
+ ModItem[] resultModItems = result != null ? result.results : null;
+ if(resultModItems != null && resultModItems.length != 0 && mPreviousResult != null) {
+ ModItem[] newModItems = new ModItem[resultModItems.length + mModItems.length];
+ System.arraycopy(mModItems, 0, newModItems, 0, mModItems.length);
+ System.arraycopy(resultModItems, 0, newModItems, mModItems.length, resultModItems.length);
+ resultModItems = newModItems;
+ }
+ ModItem[] finalModItems = resultModItems;
+ Tools.runOnUiThread(() -> {
+ if(myFuture.isCancelled()) return;
+ mTaskInProgress = null;
+ if(finalModItems == null) {
+ mSearchResultCallback.onSearchError(SearchResultCallback.ERROR_INTERNAL);
+ }else if(finalModItems.length == 0) {
+ if(mPreviousResult != null) {
+ mLastPage = true;
+ notifyItemChanged(mModItems.length);
+ mSearchResultCallback.onSearchFinished();
+ return;
+ }
+ mSearchResultCallback.onSearchError(SearchResultCallback.ERROR_NO_RESULTS);
+ }else{
+ mSearchResultCallback.onSearchFinished();
+ }
+ mCurrentResult = result;
+ if(finalModItems == null) {
+ mModItems = MOD_ITEMS_EMPTY;
+ notifyDataSetChanged();
+ return;
+ }
+ if(mPreviousResult != null) {
+ int prevLength = mModItems.length;
+ mModItems = finalModItems;
+ notifyItemChanged(prevLength);
+ notifyItemRangeInserted(prevLength+1, mModItems.length);
+ }else {
+ mModItems = finalModItems;
+ notifyDataSetChanged();
+ }
+ });
+ }
+ }
+
+ public interface SearchResultCallback {
+ int ERROR_INTERNAL = 0;
+ int ERROR_NO_RESULTS = 1;
+ void onSearchFinished();
+ void onSearchError(int error);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModloaderInstallTracker.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModloaderInstallTracker.java
new file mode 100644
index 000000000..8ec11e3fa
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModloaderInstallTracker.java
@@ -0,0 +1,106 @@
+package net.kdt.pojavlaunch.modloaders.modpacks;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+
+import net.kdt.pojavlaunch.modloaders.modpacks.api.ModLoader;
+
+import java.io.File;
+
+/**
+ * This class is meant to track the availability of a modloader that is ready to be installed (as a result of modpack installation)
+ * It is needed because having all this logic spread over LauncherActivity would be clumsy, and I think that this is the best way to
+ * ensure that the modloader installer will run, even if the user does not receive the notification or something else happens
+ */
+public class ModloaderInstallTracker implements SharedPreferences.OnSharedPreferenceChangeListener {
+ private final SharedPreferences mSharedPreferences;
+ private final Activity mActivity;
+
+ /**
+ * Create a ModInstallTracker object. This must be done in the Activity's onCreate method.
+ * @param activity the host activity
+ */
+ public ModloaderInstallTracker(Activity activity) {
+ mActivity = activity;
+ mSharedPreferences = getPreferences(activity);
+
+ }
+
+ /**
+ * Attach the ModloaderInstallTracker to the current Activity. Must be done in the Activity's
+ * onResume method
+ */
+ public void attach() {
+ mSharedPreferences.registerOnSharedPreferenceChangeListener(this);
+ runCheck();
+ }
+
+ /**
+ * Detach the ModloaderInstallTracker from the current Activity. Must be done in the Activity's
+ * onPause method
+ */
+ public void detach() {
+ mSharedPreferences.unregisterOnSharedPreferenceChangeListener(this);
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String prefName) {
+ if(!"modLoaderAvailable".equals(prefName)) return;
+ runCheck();
+ }
+
+ @SuppressLint("ApplySharedPref")
+ private void runCheck() {
+ if(!mSharedPreferences.getBoolean("modLoaderAvailable", false)) return;
+ SharedPreferences.Editor editor = mSharedPreferences.edit().putBoolean("modLoaderAvailable", false);
+ if(!editor.commit()) editor.apply();
+ ModLoader modLoader = deserializeModLoader(mSharedPreferences);
+ File modInstallFile = deserializeInstallFile(mSharedPreferences);
+ if(modLoader == null || modInstallFile == null) return;
+ startModInstallation(modLoader, modInstallFile);
+ }
+
+ private void startModInstallation(ModLoader modLoader, File modInstallFile) {
+ Intent installIntent = modLoader.getInstallationIntent(mActivity, modInstallFile);
+ mActivity.startActivity(installIntent);
+ }
+
+ private static SharedPreferences getPreferences(Context context) {
+ return context.getSharedPreferences("modloader_info", Context.MODE_PRIVATE);
+ }
+
+ /**
+ * Store the data necessary to start a ModLoader installation for the tracker to start the installer
+ * sometime.
+ * @param context the Context
+ * @param modLoader the ModLoader to store
+ * @param modInstallFile the installer jar to store
+ */
+ @SuppressLint("ApplySharedPref")
+ public static void saveModLoader(Context context, ModLoader modLoader, File modInstallFile) {
+ SharedPreferences.Editor editor = getPreferences(context).edit();
+ editor.putInt("modLoaderType", modLoader.modLoaderType);
+ editor.putString("modLoaderVersion", modLoader.modLoaderVersion);
+ editor.putString("minecraftVersion", modLoader.minecraftVersion);
+ editor.putString("modInstallerJar", modInstallFile.getAbsolutePath());
+ editor.putBoolean("modLoaderAvailable", true);
+ editor.commit();
+ }
+
+ private static ModLoader deserializeModLoader(SharedPreferences sharedPreferences) {
+ if(!sharedPreferences.contains("modLoaderType") ||
+ !sharedPreferences.contains("modLoaderVersion") ||
+ !sharedPreferences.contains("minecraftVersion")) return null;
+ return new ModLoader(sharedPreferences.getInt("modLoaderType", -1),
+ sharedPreferences.getString("modLoaderVersion", ""),
+ sharedPreferences.getString("minecraftVersion", ""));
+ }
+
+ private static File deserializeInstallFile(SharedPreferences sharedPreferences) {
+ if(!sharedPreferences.contains("modInstallerJar")) return null;
+ return new File(sharedPreferences.getString("modInstallerJar", ""));
+ }
+}
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
new file mode 100644
index 000000000..4c03ecf2b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java
@@ -0,0 +1,164 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.google.gson.Gson;
+
+import net.kdt.pojavlaunch.Tools;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+@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(additionalHeaders, baseUrl + "/" + endpoint, tClass);
+ }
+
+ public T get(String endpoint, HashMap query, Class tClass) {
+ return getFullUrl(additionalHeaders, baseUrl + "/" + endpoint, query, tClass);
+ }
+
+ public T post(String endpoint, T body, Class tClass) {
+ return postFullUrl(additionalHeaders, baseUrl + "/" + endpoint, body, tClass);
+ }
+
+ public T post(String endpoint, HashMap query, T body, Class 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);
+ inputStream.close();
+ conn.disconnect();
+ return data;
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ 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();
+ byte[] input = body.getBytes(StandardCharsets.UTF_8);
+ outputStream.write(input, 0, input.length);
+ outputStream.close();
+
+ InputStream inputStream = conn.getInputStream();
+ String data = Tools.read(inputStream);
+ inputStream.close();
+
+ conn.disconnect();
+ return data;
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ 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()) {
+ String value = Objects.toString(query.get(param));
+ params.append(urlEncodeUTF8(param))
+ .append("=")
+ .append(urlEncodeUTF8(value))
+ .append("&");
+ }
+ return params.substring(0, params.length() - 1);
+ }
+
+ public static T getFullUrl(String url, Class tClass) {
+ return getFullUrl(null, url, tClass);
+ }
+
+ public static T getFullUrl(String url, HashMap query, Class tClass) {
+ return getFullUrl(null, url, query, tClass);
+ }
+
+ public static T postFullUrl(String url, T body, Class tClass) {
+ return postFullUrl(null, url, body, tClass);
+ }
+
+ public static T postFullUrl(String url, HashMap query, T body, Class 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);
+ }
+
+ private static String urlEncodeUTF8(String input) {
+ try {
+ return URLEncoder.encode(input, "UTF-8");
+ }catch (UnsupportedEncodingException e) {
+ throw new RuntimeException("UTF-8 is required");
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java
new file mode 100644
index 000000000..d7aaef318
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java
@@ -0,0 +1,187 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import androidx.annotation.NonNull;
+
+import net.kdt.pojavlaunch.PojavApplication;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchResult;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+
+/**
+ * Group all apis under the same umbrella, as another layer of abstraction
+ */
+public class CommonApi implements ModpackApi {
+
+ private final ModpackApi mCurseforgeApi;
+ private final ModpackApi mModrinthApi;
+ private final ModpackApi[] mModpackApis;
+
+ public CommonApi(String curseforgeApiKey) {
+ mCurseforgeApi = new CurseforgeApi(curseforgeApiKey);
+ mModrinthApi = new ModrinthApi();
+ mModpackApis = new ModpackApi[]{mModrinthApi, mCurseforgeApi};
+ }
+
+ @Override
+ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previousPageResult) {
+ CommonApiSearchResult commonApiSearchResult = (CommonApiSearchResult) previousPageResult;
+ // If there are no previous page results, create a new array. Otherwise, use the one from the previous page
+ SearchResult[] results = commonApiSearchResult == null ?
+ new SearchResult[mModpackApis.length] : commonApiSearchResult.searchResults;
+
+ int totalSize = 0;
+ int totalTotalSize = 0;
+
+ Future>[] futures = new Future>[mModpackApis.length];
+ for(int i = 0; i < mModpackApis.length; i++) {
+ // If there is an array and its length is zero, this means that we've exhausted the results for this
+ // search query and we don't need to actually do the search
+ if(results[i] != null && results[i].results.length == 0) continue;
+ // If the previous page result is not null (aka the arrays aren't fresh)
+ // and the previous result is null, it means that na error has occured on the previous
+ // page. We lost contingency anyway, so don't bother requesting.
+ if(previousPageResult != null && results[i] == null) continue;
+ futures[i] = PojavApplication.sExecutorService.submit(new ApiDownloadTask(i, searchFilters,
+ results[i]));
+ }
+
+ if(Thread.interrupted()) {
+ cancelAllFutures(futures);
+ return null;
+ }
+ boolean hasSuccessful = false;
+ // Count up all the results
+ for(int i = 0; i < mModpackApis.length; i++) {
+ Future> future = futures[i];
+ if(future == null) continue;
+ try {
+ SearchResult searchResult = results[i] = (SearchResult) future.get();
+ if(searchResult != null) hasSuccessful = true;
+ else continue;
+ totalSize += searchResult.results.length;
+ totalTotalSize += searchResult.totalResultCount;
+ }catch (Exception e) {
+ cancelAllFutures(futures);
+ e.printStackTrace();
+ return null;
+ }
+ }
+ if(!hasSuccessful) {
+ return null;
+ }
+ // Then build an array with all the mods
+ ArrayList filteredResults = new ArrayList<>(results.length);
+
+ // Sanitize returned values
+ for(SearchResult result : results) {
+ if(result == null) continue;
+ ModItem[] searchResults = result.results;
+ // If the length is zero, we don't need to perform needless copies
+ if(searchResults.length == 0) continue;
+ filteredResults.add(searchResults);
+ }
+ filteredResults.trimToSize();
+ if(Thread.interrupted()) return null;
+
+ ModItem[] concatenatedItems = buildFusedResponse(filteredResults);
+ if(Thread.interrupted()) return null;
+ // Recycle or create new search result
+ if(commonApiSearchResult == null) commonApiSearchResult = new CommonApiSearchResult();
+ commonApiSearchResult.searchResults = results;
+ commonApiSearchResult.totalResultCount = totalTotalSize;
+ commonApiSearchResult.results = concatenatedItems;
+ return commonApiSearchResult;
+ }
+
+ @Override
+ public ModDetail getModDetails(ModItem item) {
+ return getModpackApi(item.apiSource).getModDetails(item);
+ }
+
+ @Override
+ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOException {
+ return getModpackApi(modDetail.apiSource).installMod(modDetail, selectedVersion);
+ }
+
+ private @NonNull ModpackApi getModpackApi(int apiSource) {
+ switch (apiSource) {
+ case Constants.SOURCE_MODRINTH:
+ return mModrinthApi;
+ case Constants.SOURCE_CURSEFORGE:
+ return mCurseforgeApi;
+ default:
+ throw new UnsupportedOperationException("Unknown API source: " + apiSource);
+ }
+ }
+
+ /** Fuse the arrays in a way that's fair for every endpoint */
+ private ModItem[] buildFusedResponse(List modMatrix){
+ int totalSize = 0;
+
+ // Calculate the total size of the merged array
+ for (ModItem[] array : modMatrix) {
+ totalSize += array.length;
+ }
+
+ ModItem[] fusedItems = new ModItem[totalSize];
+
+ int mergedIndex = 0;
+ int maxLength = 0;
+
+ // Find the maximum length of arrays
+ for (ModItem[] array : modMatrix) {
+ if (array.length > maxLength) {
+ maxLength = array.length;
+ }
+ }
+
+ // Populate the merged array
+ for (int i = 0; i < maxLength; i++) {
+ for (ModItem[] matrix : modMatrix) {
+ if (i < matrix.length) {
+ fusedItems[mergedIndex] = matrix[i];
+ mergedIndex++;
+ }
+ }
+ }
+
+ return fusedItems;
+ }
+
+ private void cancelAllFutures(Future>[] futures) {
+ for(Future> future : futures) {
+ if(future == null) continue;
+ future.cancel(true);
+ }
+ }
+
+ private class ApiDownloadTask implements Callable {
+ private final int mModApi;
+ private final SearchFilters mSearchFilters;
+ private final SearchResult mPreviousPageResult;
+
+ private ApiDownloadTask(int modApi, SearchFilters searchFilters, SearchResult previousPageResult) {
+ this.mModApi = modApi;
+ this.mSearchFilters = searchFilters;
+ this.mPreviousPageResult = previousPageResult;
+ }
+
+ @Override
+ public SearchResult call() {
+ return mModpackApis[mModApi].searchMod(mSearchFilters, mPreviousPageResult);
+ }
+ }
+
+ class CommonApiSearchResult extends SearchResult {
+ SearchResult[] searchResults = new SearchResult[mModpackApis.length];
+ }
+}
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..0920509bf
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java
@@ -0,0 +1,242 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.R;
+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.SearchFilters;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchResult;
+import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
+import net.kdt.pojavlaunch.utils.FileUtils;
+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_SORT_RELEVANCY = 1;
+ private static final int CURSEFORGE_PAGINATION_SIZE = 50;
+ private static final int CURSEFORGE_PAGINATION_END_REACHED = -1;
+ private static final int CURSEFORGE_PAGINATION_ERROR = -2;
+
+ private final ApiHandler mApiHandler;
+ public CurseforgeApi(String apiKey) {
+ mApiHandler = new ApiHandler("https://api.curseforge.com/v1", apiKey);
+ }
+
+ @Override
+ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previousPageResult) {
+ CurseforgeSearchResult curseforgeSearchResult = (CurseforgeSearchResult) previousPageResult;
+
+ 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);
+ params.put("sortField", CURSEFORGE_SORT_RELEVANCY);
+ params.put("sortOrder", "desc");
+ if(searchFilters.mcVersion != null && !searchFilters.mcVersion.isEmpty())
+ params.put("gameVersion", searchFilters.mcVersion);
+ if(previousPageResult != null)
+ params.put("index", curseforgeSearchResult.previousOffset);
+
+ JsonObject response = mApiHandler.get("mods/search", params, JsonObject.class);
+ if(response == null) return null;
+ JsonArray dataArray = response.getAsJsonArray("data");
+ if(dataArray == null) return null;
+ JsonObject paginationInfo = response.getAsJsonObject("pagination");
+ 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);
+ }
+ if(curseforgeSearchResult == null) curseforgeSearchResult = new CurseforgeSearchResult();
+ curseforgeSearchResult.results = modItemList.toArray(new ModItem[0]);
+ curseforgeSearchResult.totalResultCount = paginationInfo.get("totalCount").getAsInt();
+ curseforgeSearchResult.previousOffset += dataArray.size();
+ return curseforgeSearchResult;
+
+ }
+
+ @Override
+ public ModDetail getModDetails(ModItem item) {
+ ArrayList allModDetails = new ArrayList<>();
+ int index = 0;
+ while(index != CURSEFORGE_PAGINATION_END_REACHED &&
+ index != CURSEFORGE_PAGINATION_ERROR) {
+ index = getPaginatedDetails(allModDetails, index, item.id);
+ }
+ if(index == CURSEFORGE_PAGINATION_ERROR) 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 ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOException{
+ //TODO considering only modpacks for now
+ return ModpackInstaller.installModpack(modDetail, selectedVersion, this::installCurseforgeZip);
+ }
+
+
+ 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 CURSEFORGE_PAGINATION_ERROR;
+ JsonArray data = response.getAsJsonArray("data");
+ if(data == null) return CURSEFORGE_PAGINATION_ERROR;
+ 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 CURSEFORGE_PAGINATION_END_REACHED; // we read the remainder! yay!
+ }
+ return index + data.size();
+ }
+
+ private ModLoader 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);
+ if(!verifyManifest(curseManifest)) {
+ Log.i("CurseforgeApi","manifest verification failed");
+ return null;
+ }
+ ModDownloader modDownloader = new ModDownloader(new File(instanceDestination,"mods"), true);
+ int fileCount = curseManifest.files.length;
+ for(int i = 0; i < fileCount; i++) {
+ final CurseManifest.CurseFile curseFile = curseManifest.files[i];
+ modDownloader.submitDownload(()->{
+ String url = getDownloadUrl(curseFile.projectID, curseFile.fileID);
+ if(url == null && curseFile.required)
+ throw new IOException("Failed to obtain download URL for "+curseFile.projectID+" "+curseFile.fileID);
+ else if(url == null) return null;
+ return new ModDownloader.FileInfo(url, FileUtils.getFileName(url));
+ });
+ }
+ modDownloader.awaitFinish((c,m)->
+ ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, (int) Math.max((float)c/m*100,0), R.string.modpack_download_downloading_mods_fc, c, m)
+ );
+ String overridesDir = "overrides";
+ if(curseManifest.overrides != null) overridesDir = curseManifest.overrides;
+ ZipUtils.zipExtract(modpackZipFile, overridesDir, instanceDestination);
+ return createInfo(curseManifest.minecraft);
+ }
+ }
+
+ private ModLoader createInfo(CurseManifest.CurseMinecraft minecraft) {
+ CurseManifest.CurseModLoader primaryModLoader = null;
+ for(CurseManifest.CurseModLoader modLoader : minecraft.modLoaders) {
+ if(modLoader.primary) {
+ primaryModLoader = modLoader;
+ break;
+ }
+ }
+ if(primaryModLoader == null) primaryModLoader = minecraft.modLoaders[0];
+ String modLoaderId = primaryModLoader.id;
+ int dashIndex = modLoaderId.indexOf('-');
+ String modLoaderName = modLoaderId.substring(0, dashIndex);
+ String modLoaderVersion = modLoaderId.substring(dashIndex+1);
+ Log.i("CurseforgeApi", modLoaderId + " " + modLoaderName + " "+modLoaderVersion);
+ int modLoaderTypeInt;
+ switch (modLoaderName) {
+ case "forge":
+ modLoaderTypeInt = ModLoader.MOD_LOADER_FORGE;
+ break;
+ case "fabric":
+ modLoaderTypeInt = ModLoader.MOD_LOADER_FABRIC;
+ break;
+ default:
+ return null;
+ //TODO: Quilt is also Forge? How does that work?
+ }
+ return new ModLoader(modLoaderTypeInt, modLoaderVersion, minecraft.version);
+ }
+
+ private String getDownloadUrl(long projectID, long fileID) {
+ // First try the official api endpoint
+ JsonObject response = mApiHandler.get("mods/"+projectID+"/files/"+fileID+"/download-url", JsonObject.class);
+ if (response != null && !response.get("data").isJsonNull())
+ return response.get("data").getAsString();
+
+ // Otherwise, fallback to building an edge link
+ JsonObject fallbackResponse = mApiHandler.get(String.format("mods/%s/files/%s", projectID, fileID), JsonObject.class);
+ if (fallbackResponse != null && !fallbackResponse.get("data").isJsonNull()){
+ JsonObject modData = fallbackResponse.get("data").getAsJsonObject();
+ int id = modData.get("id").getAsInt();
+ return String.format("https://edge.forgecdn.net/files/%s/%s/%s", id/1000, id % 1000, modData.get("fileName").getAsString());
+ }
+
+ return null;
+ }
+
+ private boolean verifyManifest(CurseManifest manifest) {
+ if(!"minecraftModpack".equals(manifest.manifestType)) return false;
+ if(manifest.manifestVersion != 1) return false;
+ if(manifest.minecraft == null) return false;
+ if(manifest.minecraft.version == null) return false;
+ if(manifest.minecraft.modLoaders == null) return false;
+ if(manifest.minecraft.modLoaders.length < 1) return false;
+ return true;
+ }
+
+ class CurseforgeSearchResult extends SearchResult {
+ int previousOffset;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModDownloader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModDownloader.java
new file mode 100644
index 000000000..09ddd7c6e
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModDownloader.java
@@ -0,0 +1,172 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class ModDownloader {
+ private static final ThreadLocal sThreadLocalBuffer = new ThreadLocal<>();
+ private final ThreadPoolExecutor mDownloadPool = new ThreadPoolExecutor(4,4,100, TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<>());
+ private final AtomicBoolean mTerminator = new AtomicBoolean(false);
+ private final AtomicLong mDownloadSize = new AtomicLong(0);
+ private final Object mExceptionSyncPoint = new Object();
+ private final File mDestinationDirectory;
+ private final boolean mUseFileCount;
+ private IOException mFirstIOException;
+ private long mTotalSize;
+
+ public ModDownloader(File destinationDirectory) {
+ this(destinationDirectory, false);
+ }
+
+ public ModDownloader(File destinationDirectory, boolean useFileCount) {
+ this.mDownloadPool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
+ this.mDestinationDirectory = destinationDirectory;
+ this.mUseFileCount = useFileCount;
+ }
+
+ public void submitDownload(int fileSize, String relativePath, String... url) {
+ if(mUseFileCount) mTotalSize += 1;
+ else mTotalSize += fileSize;
+ mDownloadPool.execute(new DownloadTask(url, new File(mDestinationDirectory, relativePath)));
+ }
+
+ public void submitDownload(FileInfoProvider infoProvider) {
+ if(!mUseFileCount) throw new RuntimeException("This method can only be used in a file-counting ModDownloader");
+ mTotalSize += 1;
+ mDownloadPool.execute(new FileInfoQueryTask(infoProvider));
+ }
+
+ public void awaitFinish(Tools.DownloaderFeedback feedback) throws IOException {
+ try {
+ mDownloadPool.shutdown();
+ while(!mDownloadPool.awaitTermination(20, TimeUnit.MILLISECONDS) && !mTerminator.get()) {
+ feedback.updateProgress((int) mDownloadSize.get(), (int) mTotalSize);
+ }
+ if(mTerminator.get()) {
+ mDownloadPool.shutdownNow();
+ synchronized (mExceptionSyncPoint) {
+ if(mFirstIOException == null) mExceptionSyncPoint.wait();
+ throw mFirstIOException;
+ }
+ }
+ }catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private static byte[] getThreadLocalBuffer() {
+ byte[] buffer = sThreadLocalBuffer.get();
+ if(buffer != null) return buffer;
+ buffer = new byte[8192];
+ sThreadLocalBuffer.set(buffer);
+ return buffer;
+ }
+
+ private void downloadFailed(IOException exception) {
+ mTerminator.set(true);
+ synchronized (mExceptionSyncPoint) {
+ if(mFirstIOException == null) {
+ mFirstIOException = exception;
+ mExceptionSyncPoint.notify();
+ }
+ }
+ }
+
+ class FileInfoQueryTask implements Runnable {
+ private final FileInfoProvider mFileInfoProvider;
+ public FileInfoQueryTask(FileInfoProvider fileInfoProvider) {
+ this.mFileInfoProvider = fileInfoProvider;
+ }
+ @Override
+ public void run() {
+ try {
+ FileInfo fileInfo = mFileInfoProvider.getFileInfo();
+ if(fileInfo == null) return;
+ new DownloadTask(new String[]{fileInfo.url},
+ new File(mDestinationDirectory, fileInfo.relativePath)).run();
+ }catch (IOException e) {
+ downloadFailed(e);
+ }
+ }
+ }
+
+ class DownloadTask implements Runnable, Tools.DownloaderFeedback {
+ private final String[] mDownloadUrls;
+ private final File mDestination;
+ private int last = 0;
+
+ public DownloadTask(String[] downloadurls,
+ File downloadDestination) {
+ this.mDownloadUrls = downloadurls;
+ this.mDestination = downloadDestination;
+ }
+
+ @Override
+ public void run() {
+ IOException exception = null;
+ for(String sourceUrl : mDownloadUrls) {
+ try {
+ exception = tryDownload(sourceUrl);
+ if(exception == null) return;
+ }catch (InterruptedException e) {
+ return;
+ }
+ }
+ if(exception != null) {
+ downloadFailed(exception);
+ }
+ }
+
+ private IOException tryDownload(String sourceUrl) throws InterruptedException {
+ IOException exception = null;
+ for (int i = 0; i < 5; i++) {
+ try {
+ DownloadUtils.downloadFileMonitored(sourceUrl, mDestination, getThreadLocalBuffer(), this);
+ if(mUseFileCount) mDownloadSize.addAndGet(1);
+ return null;
+ } catch (InterruptedIOException e) {
+ throw new InterruptedException();
+ } catch (IOException e) {
+ e.printStackTrace();
+ exception = e;
+ }
+ if(!mUseFileCount) {
+ mDownloadSize.addAndGet(-last);
+ last = 0;
+ }
+ }
+ return exception;
+ }
+
+ @Override
+ public void updateProgress(int curr, int max) {
+ if(mUseFileCount) return;
+ mDownloadSize.addAndGet(curr - last);
+ last = curr;
+ }
+ }
+
+ public static class FileInfo {
+ public final String url;
+ public final String relativePath;
+
+ public FileInfo(String url, String relativePath) {
+ this.url = url;
+ this.relativePath = relativePath;
+ }
+ }
+
+ public interface FileInfoProvider {
+ FileInfo getFileInfo() throws IOException;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModLoader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModLoader.java
new file mode 100644
index 000000000..c47cfd48a
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModLoader.java
@@ -0,0 +1,87 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import android.content.Context;
+import android.content.Intent;
+
+import net.kdt.pojavlaunch.JavaGUILauncherActivity;
+import net.kdt.pojavlaunch.modloaders.FabricDownloadTask;
+import net.kdt.pojavlaunch.modloaders.FabricUtils;
+import net.kdt.pojavlaunch.modloaders.ForgeDownloadTask;
+import net.kdt.pojavlaunch.modloaders.ForgeUtils;
+import net.kdt.pojavlaunch.modloaders.ModloaderDownloadListener;
+
+import java.io.File;
+
+public class ModLoader {
+ public static final int MOD_LOADER_FORGE = 0;
+ public static final int MOD_LOADER_FABRIC = 1;
+ public static final int MOD_LOADER_QUILT = 2;
+ public final int modLoaderType;
+ public final String modLoaderVersion;
+ public final String minecraftVersion;
+
+ public ModLoader(int modLoaderType, String modLoaderVersion, String minecraftVersion) {
+ this.modLoaderType = modLoaderType;
+ this.modLoaderVersion = modLoaderVersion;
+ this.minecraftVersion = minecraftVersion;
+ }
+
+ /**
+ * Get the Version ID (the name of the mod loader in the versions/ folder)
+ * @return the Version ID as a string
+ */
+ public String getVersionId() {
+ switch (modLoaderType) {
+ case MOD_LOADER_FORGE:
+ return minecraftVersion+"-forge-"+modLoaderVersion;
+ case MOD_LOADER_FABRIC:
+ return "fabric-loader-"+modLoaderVersion+"-"+minecraftVersion;
+ case MOD_LOADER_QUILT:
+ throw new RuntimeException("Quilt is not supported");
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Get the Runnable that needs to run in order to download the mod loader
+ * @param listener the listener that gets notified of the installation status
+ * @return the task Runnable that needs to be ran
+ */
+ public Runnable getDownloadTask(ModloaderDownloadListener listener) {
+ switch (modLoaderType) {
+ case MOD_LOADER_FORGE:
+ return new ForgeDownloadTask(listener, minecraftVersion, modLoaderVersion);
+ case MOD_LOADER_FABRIC:
+ return new FabricDownloadTask(listener);
+ case MOD_LOADER_QUILT:
+ throw new RuntimeException("Quilt is not supported");
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Get the Intent to start the graphical installation of the mod loader.
+ * This method should only be ran after the download task of the specified mod loader finishes.
+ * @param context the package resolving Context (can be the base context)
+ * @param modInstallerJar the JAR file of the mod installer, provided by ModloaderDownloadListener after the installation
+ * finishes.
+ * @return the Intent which the launcher needs to start in order to install the mod loader
+ */
+ public Intent getInstallationIntent(Context context, File modInstallerJar) {
+ Intent baseIntent = new Intent(context, JavaGUILauncherActivity.class);
+ switch (modLoaderType) {
+ case MOD_LOADER_FORGE:
+ ForgeUtils.addAutoInstallArgs(baseIntent, modInstallerJar, getVersionId());
+ return baseIntent;
+ case MOD_LOADER_FABRIC:
+ FabricUtils.addAutoInstallArgs(baseIntent, modInstallerJar, minecraftVersion, modLoaderVersion, false, false);
+ return baseIntent;
+ case MOD_LOADER_QUILT:
+ throw new RuntimeException("Quilt is not supported");
+ default:
+ return null;
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackApi.java
new file mode 100644
index 000000000..141468af8
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackApi.java
@@ -0,0 +1,73 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+
+import android.content.Context;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.PojavApplication;
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchResult;
+
+import java.io.IOException;
+
+/**
+ *
+ */
+public interface ModpackApi {
+
+ /**
+ * @param searchFilters Filters
+ * @param previousPageResult The result from the previous page
+ * @return the list of mod items from specified offset
+ */
+ SearchResult searchMod(SearchFilters searchFilters, SearchResult previousPageResult);
+
+ /**
+ * @param searchFilters Filters
+ * @return A list of mod items
+ */
+ default SearchResult searchMod(SearchFilters searchFilters) {
+ return searchMod(searchFilters, null);
+ }
+
+ /**
+ * Fetch the mod details
+ * @param item The moditem that was selected
+ * @return Detailed data about a mod(pack)
+ */
+ ModDetail getModDetails(ModItem item);
+
+ /**
+ * Download and install the mod(pack)
+ * @param modDetail The mod detail data
+ * @param selectedVersion The selected version
+ */
+ default void handleInstallation(Context context, ModDetail modDetail, int selectedVersion) {
+ // Doing this here since when starting installation, the progress does not start immediately
+ // which may lead to two concurrent installations (very bad)
+ ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.global_waiting);
+ PojavApplication.sExecutorService.execute(() -> {
+ try {
+ ModLoader loaderInfo = installMod(modDetail, selectedVersion);
+ if (loaderInfo == null) return;
+ loaderInfo.getDownloadTask(new NotificationDownloadListener(context, loaderInfo)).run();
+ }catch (IOException e) {
+ Tools.showErrorRemote(context, R.string.modpack_install_download_failed, e);
+ }
+ });
+ }
+
+ /**
+ * Install the mod(pack).
+ * May require the download of additional files.
+ * May requires launching the installation of a modloader
+ * @param modDetail The mod detail data
+ * @param selectedVersion The selected version
+ */
+ ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOException;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackInstaller.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackInstaller.java
new file mode 100644
index 000000000..708754280
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModpackInstaller.java
@@ -0,0 +1,63 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.ModloaderDownloadListener;
+import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.ModIconCache;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail;
+import net.kdt.pojavlaunch.progresskeeper.DownloaderProgressWrapper;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
+import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Locale;
+
+public class ModpackInstaller {
+
+ public static ModLoader installModpack(ModDetail modDetail, int selectedVersion, InstallFunction installFunction) throws IOException{
+ String versionUrl = modDetail.versionUrls[selectedVersion];
+ String modpackName = modDetail.title.toLowerCase(Locale.ROOT).trim().replace(" ", "_" );
+
+ // Build a new minecraft instance, folder first
+
+ // Get the modpack file
+ File modpackFile = new File(Tools.DIR_CACHE, modpackName + ".cf"); // Cache File
+ ModLoader modLoaderInfo;
+ try {
+ byte[] downloadBuffer = new byte[8192];
+ DownloadUtils.downloadFileMonitored(versionUrl, modpackFile, downloadBuffer,
+ new DownloaderProgressWrapper(R.string.modpack_download_downloading_metadata,
+ ProgressLayout.INSTALL_MODPACK));
+ // Install the modpack
+ modLoaderInfo = installFunction.installModpack(modpackFile, new File(Tools.DIR_GAME_HOME, "custom_instances/"+modpackName));
+
+ } finally {
+ modpackFile.delete();
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
+ }
+ if(modLoaderInfo == null) {
+ return null;
+ }
+
+ // Create the instance
+ MinecraftProfile profile = new MinecraftProfile();
+ profile.gameDir = "./custom_instances/" + modpackName;
+ profile.name = modDetail.title;
+ profile.lastVersionId = modLoaderInfo.getVersionId();
+ profile.icon = ModIconCache.getBase64Image(modDetail.getIconCacheTag());
+
+
+ LauncherProfiles.mainProfileJson.profiles.put(modpackName, profile);
+ LauncherProfiles.write();
+
+ return modLoaderInfo;
+ }
+
+ interface InstallFunction {
+ ModLoader installModpack(File modpackFile, File instanceDestination) throws IOException;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java
new file mode 100644
index 000000000..e8eded460
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java
@@ -0,0 +1,139 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants;
+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.modloaders.modpacks.models.SearchResult;
+import net.kdt.pojavlaunch.progresskeeper.DownloaderProgressWrapper;
+import net.kdt.pojavlaunch.utils.ZipUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.ZipFile;
+
+public class ModrinthApi implements ModpackApi{
+ private final ApiHandler mApiHandler;
+ public ModrinthApi(){
+ mApiHandler = new ApiHandler("https://api.modrinth.com/v2");
+ }
+
+ @Override
+ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previousPageResult) {
+ ModrinthSearchResult modrinthSearchResult = (ModrinthSearchResult) previousPageResult;
+ HashMap params = new HashMap<>();
+
+ // Build the facets filters
+ StringBuilder facetString = new StringBuilder();
+ facetString.append("[");
+ facetString.append(String.format("[\"project_type:%s\"]", searchFilters.isModpack ? "modpack" : "mod"));
+ if(searchFilters.mcVersion != null && !searchFilters.mcVersion.isEmpty())
+ facetString.append(String.format(",[\"versions:%s\"]", searchFilters.mcVersion));
+ facetString.append("]");
+ params.put("facets", facetString.toString());
+ params.put("query", searchFilters.name.replace(' ', '+'));
+ params.put("limit", 50);
+ params.put("index", "relevance");
+ if(modrinthSearchResult != null)
+ params.put("offset", modrinthSearchResult.previousOffset);
+
+ JsonObject response = mApiHandler.get("search", params, JsonObject.class);
+ if(response == null) return null;
+ JsonArray responseHits = response.getAsJsonArray("hits");
+ if(responseHits == null) return null;
+
+ ModItem[] items = new ModItem[responseHits.size()];
+ for(int i=0; i dependencies = modrinthIndex.dependencies;
+ String mcVersion = dependencies.get("minecraft");
+ if(mcVersion == null) return null;
+ String modLoaderVersion;
+ if((modLoaderVersion = dependencies.get("forge")) != null) {
+ return new ModLoader(ModLoader.MOD_LOADER_FORGE, modLoaderVersion, mcVersion);
+ }
+ if((modLoaderVersion = dependencies.get("fabric-loader")) != null) {
+ return new ModLoader(ModLoader.MOD_LOADER_FABRIC, modLoaderVersion, mcVersion);
+ }
+ if((modLoaderVersion = dependencies.get("quilt-loader")) != null) {
+ return new ModLoader(ModLoader.MOD_LOADER_QUILT, modLoaderVersion, mcVersion);
+ }
+ return null;
+ }
+
+ private ModLoader installMrpack(File mrpackFile, File instanceDestination) throws IOException {
+ try (ZipFile modpackZipFile = new ZipFile(mrpackFile)){
+ ModrinthIndex modrinthIndex = Tools.GLOBAL_GSON.fromJson(
+ Tools.read(ZipUtils.getEntryStream(modpackZipFile, "modrinth.index.json")),
+ ModrinthIndex.class);
+
+ ModDownloader modDownloader = new ModDownloader(instanceDestination);
+ for(ModrinthIndex.ModrinthIndexFile indexFile : modrinthIndex.files) {
+ modDownloader.submitDownload(indexFile.fileSize, indexFile.path, indexFile.downloads);
+ }
+ modDownloader.awaitFinish(new DownloaderProgressWrapper(R.string.modpack_download_downloading_mods, ProgressLayout.INSTALL_MODPACK));
+ ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.modpack_download_applying_overrides, 1, 2);
+ ZipUtils.zipExtract(modpackZipFile, "overrides/", instanceDestination);
+ ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 50, R.string.modpack_download_applying_overrides, 2, 2);
+ ZipUtils.zipExtract(modpackZipFile, "client-overrides/", instanceDestination);
+ return createInfo(modrinthIndex);
+ }
+ }
+
+ class ModrinthSearchResult extends SearchResult {
+ int previousOffset;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/NotificationDownloadListener.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/NotificationDownloadListener.java
new file mode 100644
index 000000000..98f4be4e6
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/NotificationDownloadListener.java
@@ -0,0 +1,67 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+
+import androidx.core.app.NotificationCompat;
+
+import net.kdt.pojavlaunch.LauncherActivity;
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.ModloaderDownloadListener;
+import net.kdt.pojavlaunch.modloaders.modpacks.ModloaderInstallTracker;
+import net.kdt.pojavlaunch.value.NotificationConstants;
+
+import java.io.File;
+
+public class NotificationDownloadListener implements ModloaderDownloadListener {
+
+ private final NotificationCompat.Builder mNotificationBuilder;
+ private final NotificationManager mNotificationManager;
+ private final Context mContext;
+ private final ModLoader mModLoader;
+
+ public NotificationDownloadListener(Context context, ModLoader modLoader) {
+ mModLoader = modLoader;
+ mContext = context.getApplicationContext();
+ mNotificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ mNotificationBuilder = new NotificationCompat.Builder(context, "channel_id")
+ .setContentTitle(context.getString(R.string.modpack_install_notification_title))
+ .setSmallIcon(R.drawable.notif_icon);
+ }
+
+ @Override
+ public void onDownloadFinished(File downloadedFile) {
+ ModloaderInstallTracker.saveModLoader(mContext, mModLoader, downloadedFile);
+ Intent mainActivityIntent = new Intent(mContext, LauncherActivity.class);
+ Tools.runOnUiThread(()->sendIntentNotification(mainActivityIntent, R.string.modpack_install_notification_success));
+ }
+
+ @Override
+ public void onDataNotAvailable() {
+ Tools.runOnUiThread(()->sendEmptyNotification(R.string.modpack_install_notification_data_not_available));
+ }
+
+ @Override
+ public void onDownloadError(Exception e) {
+ Tools.showErrorRemote(mContext, R.string.modpack_install_modloader_download_failed, e);
+ }
+
+ private void sendIntentNotification(Intent intent, int contentText) {
+ PendingIntent pendingInstallIntent =
+ PendingIntent.getActivity(mContext, NotificationConstants.PENDINGINTENT_CODE_DOWNLOAD_SERVICE,
+ intent, Build.VERSION.SDK_INT >=23 ? PendingIntent.FLAG_IMMUTABLE : 0);
+
+ mNotificationBuilder.setContentText(mContext.getText(contentText));
+ mNotificationBuilder.setContentIntent(pendingInstallIntent);
+ mNotificationManager.notify(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_LISTENER, mNotificationBuilder.build());
+ }
+
+ private void sendEmptyNotification(int contentText) {
+ mNotificationBuilder.setContentText(mContext.getText(contentText));
+ mNotificationManager.notify(NotificationConstants.NOTIFICATION_ID_DOWNLOAD_LISTENER, mNotificationBuilder.build());
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/DownloadImageTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/DownloadImageTask.java
new file mode 100644
index 000000000..9c9bdc942
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/DownloadImageTask.java
@@ -0,0 +1,61 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.imagecache;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+class DownloadImageTask implements Runnable {
+ private static final float BITMAP_FINAL_DIMENSION = 256f;
+ private final ReadFromDiskTask mParentTask;
+ private int mRetryCount;
+ DownloadImageTask(ReadFromDiskTask parentTask) {
+ this.mParentTask = parentTask;
+ this.mRetryCount = 0;
+ }
+
+ @Override
+ public void run() {
+ boolean wasSuccessful = false;
+ while(mRetryCount < 5 && !(wasSuccessful = runCatching())) {
+ mRetryCount++;
+ }
+ // restart the parent task to read the image and send it to the receiver
+ // if it wasn't cancelled. If it was, then we just die here
+ if(wasSuccessful && !mParentTask.taskCancelled())
+ mParentTask.iconCache.cacheLoaderPool.execute(mParentTask);
+ }
+
+ public boolean runCatching() {
+ try {
+ IconCacheJanitor.waitForJanitorToFinish();
+ DownloadUtils.downloadFile(mParentTask.imageUrl, mParentTask.cacheFile);
+ Bitmap bitmap = BitmapFactory.decodeFile(mParentTask.cacheFile.getAbsolutePath());
+ if(bitmap == null) return false;
+ int bitmapWidth = bitmap.getWidth(), bitmapHeight = bitmap.getHeight();
+ if(bitmapWidth <= BITMAP_FINAL_DIMENSION && bitmapHeight <= BITMAP_FINAL_DIMENSION) {
+ bitmap.recycle();
+ return true;
+ }
+ float imageRescaleRatio = Math.min(BITMAP_FINAL_DIMENSION/bitmapWidth, BITMAP_FINAL_DIMENSION/bitmapHeight);
+ Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap,
+ (int)(bitmapWidth * imageRescaleRatio),
+ (int)(bitmapHeight * imageRescaleRatio),
+ true);
+ bitmap.recycle();
+ if(resizedBitmap == bitmap) return true;
+ try (FileOutputStream fileOutputStream = new FileOutputStream(mParentTask.cacheFile)) {
+ resizedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, fileOutputStream);
+ } finally {
+ resizedBitmap.recycle();
+ }
+ return true;
+ }catch (IOException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/IconCacheJanitor.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/IconCacheJanitor.java
new file mode 100644
index 000000000..f35fadeaa
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/IconCacheJanitor.java
@@ -0,0 +1,86 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.imagecache;
+
+import android.util.Log;
+
+import net.kdt.pojavlaunch.PojavApplication;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+/**
+ * This image is intended to keep the mod icon cache tidy (aka under 100 megabytes)
+ */
+public class IconCacheJanitor implements Runnable{
+ public static final long CACHE_SIZE_LIMIT = 104857600; // The cache size limit, 100 megabytes
+ public static final long CACHE_BRINGDOWN = 52428800; // The size to which the cache should be brought
+ // in case of an overflow, 50 mb
+ private static Future> sJanitorFuture;
+ private static boolean sJanitorRan = false;
+ private IconCacheJanitor() {
+ // don't allow others to create this
+ }
+ @Override
+ public void run() {
+ File modIconCachePath = ModIconCache.getImageCachePath();
+ if(!modIconCachePath.isDirectory() || !modIconCachePath.canRead()) return;
+ File[] modIconFiles = modIconCachePath.listFiles();
+ if(modIconFiles == null) return;
+ ArrayList writableModIconFiles = new ArrayList<>(modIconFiles.length);
+ long directoryFileSize = 0;
+ for(File modIconFile : modIconFiles) {
+ if(!modIconFile.isFile() || !modIconFile.canRead()) continue;
+ directoryFileSize += modIconFile.length();
+ if(!modIconFile.canWrite()) continue;
+ writableModIconFiles.add(modIconFile);
+ }
+ if(directoryFileSize < CACHE_SIZE_LIMIT) {
+ Log.i("IconCacheJanitor", "Skipping cleanup because there's not enough to clean up");
+ return;
+ }
+ Arrays.sort(modIconFiles,
+ (x,y)-> Long.compare(y.lastModified(), x.lastModified())
+ );
+ int filesCleanedUp = 0;
+ for(File modFile : writableModIconFiles) {
+ if(directoryFileSize < CACHE_BRINGDOWN) break;
+ long modFileSize = modFile.length();
+ if(modFile.delete()) {
+ directoryFileSize -= modFileSize;
+ filesCleanedUp++;
+ }
+ }
+ Log.i("IconCacheJanitor", "Cleaned up "+filesCleanedUp+ " files");
+ synchronized (IconCacheJanitor.class) {
+ sJanitorFuture = null;
+ sJanitorRan = true;
+ }
+ }
+
+ /**
+ * Runs the janitor task, unless there was one running already or one has ran already
+ */
+ public static void runJanitor() {
+ synchronized (IconCacheJanitor.class) {
+ if (sJanitorFuture != null || sJanitorRan) return;
+ sJanitorFuture = PojavApplication.sExecutorService.submit(new IconCacheJanitor());
+ }
+ }
+
+ /**
+ * Waits for the janitor task to finish, if there is one running already
+ * Note that the thread waiting must not be interrupted.
+ */
+ public static void waitForJanitorToFinish() {
+ synchronized (IconCacheJanitor.class) {
+ if (sJanitorFuture == null) return;
+ try {
+ sJanitorFuture.get();
+ } catch (ExecutionException | InterruptedException e) {
+ throw new RuntimeException("Should not happen!", e);
+ }
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ImageReceiver.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ImageReceiver.java
new file mode 100644
index 000000000..f405b657c
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ImageReceiver.java
@@ -0,0 +1,10 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.imagecache;
+
+import android.graphics.Bitmap;
+
+/**
+ * ModIconCache will call your view back when the image becomes available with this interface
+ */
+public interface ImageReceiver {
+ void onImageAvailable(Bitmap image);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ModIconCache.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ModIconCache.java
new file mode 100644
index 000000000..af1775633
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ModIconCache.java
@@ -0,0 +1,109 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.imagecache;
+
+import android.util.Base64;
+import android.util.Log;
+
+import net.kdt.pojavlaunch.Tools;
+
+import org.apache.commons.io.IOUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+public class ModIconCache {
+ ThreadPoolExecutor cacheLoaderPool = new ThreadPoolExecutor(10,
+ 10,
+ 1000,
+ TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<>());
+ File cachePath;
+ private final List> mCancelledReceivers = new ArrayList<>();
+ public ModIconCache() {
+ cachePath = getImageCachePath();
+ if(!cachePath.exists() && !cachePath.isFile() && Tools.DIR_CACHE.canWrite()) {
+ if(!cachePath.mkdirs())
+ throw new RuntimeException("Failed to create icon cache directory");
+ }
+
+ }
+ static File getImageCachePath() {
+ return new File(Tools.DIR_CACHE, "mod_icons");
+ }
+
+ /**
+ * Get an image for a mod with the associated tag and URL to download it in case if its not cached
+ * @param imageReceiver the receiver interface that would get called when the image loads
+ * @param imageTag the tag of the image to keep track of it
+ * @param imageUrl the URL of the image in case if it's not cached
+ */
+ public void getImage(ImageReceiver imageReceiver, String imageTag, String imageUrl) {
+ cacheLoaderPool.execute(new ReadFromDiskTask(this, imageReceiver, imageTag, imageUrl));
+ }
+
+ /**
+ * Mark the image obtainment task requested with this receiver as "cancelled". This means that
+ * this receiver will not be called back and that some tasks related to this image may be
+ * prevented from happening or interrupted.
+ * @param imageReceiver the receiver to cancel
+ */
+ public void cancelImage(ImageReceiver imageReceiver) {
+ synchronized (mCancelledReceivers) {
+ mCancelledReceivers.add(new WeakReference<>(imageReceiver));
+ }
+ }
+
+ boolean checkCancelled(ImageReceiver imageReceiver) {
+ boolean isCanceled = false;
+ synchronized (mCancelledReceivers) {
+ Iterator> iterator = mCancelledReceivers.iterator();
+ while (iterator.hasNext()) {
+ WeakReference reference = iterator.next();
+ if (reference.get() == null) {
+ iterator.remove();
+ continue;
+ }
+ if(reference.get() == imageReceiver) {
+ isCanceled = true;
+ }
+ }
+ }
+ if(isCanceled) Log.i("IconCache", "checkCancelled("+imageReceiver.hashCode()+") == true");
+ return isCanceled;
+ }
+
+ /**
+ * Get the base64-encoded version of a cached icon by its tag.
+ * Note: this functions performs I/O operations, and should not be called on the UI
+ * thread.
+ * @param imageTag the icon tag
+ * @return the base64 encoded image or null if not cached
+ */
+
+ public static String getBase64Image(String imageTag) {
+ File imagePath = new File(Tools.DIR_CACHE, "mod_icons/"+imageTag+".ca");
+ Log.i("IconCache", "Creating base64 version of icon "+imageTag);
+ if(!imagePath.canRead() || !imagePath.isFile()) {
+ Log.i("IconCache", "Icon does not exist");
+ return null;
+ }
+ try {
+ try(FileInputStream fileInputStream = new FileInputStream(imagePath)) {
+ byte[] imageBytes = IOUtils.toByteArray(fileInputStream);
+ // reencode to png? who cares! our profile icon cache is an omnivore!
+ // if some other launcher parses this and dies it is not our problem :troll:
+ return "data:image/png;base64,"+ Base64.encodeToString(imageBytes, Base64.DEFAULT);
+ }
+ }catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ReadFromDiskTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ReadFromDiskTask.java
new file mode 100644
index 000000000..89f3ed41c
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/imagecache/ReadFromDiskTask.java
@@ -0,0 +1,55 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.imagecache;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import net.kdt.pojavlaunch.Tools;
+
+import java.io.File;
+
+public class ReadFromDiskTask implements Runnable {
+ final ModIconCache iconCache;
+ final ImageReceiver imageReceiver;
+ final File cacheFile;
+ final String imageUrl;
+
+ ReadFromDiskTask(ModIconCache iconCache, ImageReceiver imageReceiver, String cacheTag, String imageUrl) {
+ this.iconCache = iconCache;
+ this.imageReceiver = imageReceiver;
+ this.cacheFile = new File(iconCache.cachePath, cacheTag+".ca");
+ this.imageUrl = imageUrl;
+ }
+
+ public void runDownloadTask() {
+ iconCache.cacheLoaderPool.execute(new DownloadImageTask(this));
+ }
+
+ @Override
+ public void run() {
+ if(cacheFile.isDirectory()) {
+ return;
+ }
+ if(cacheFile.canRead()) {
+ IconCacheJanitor.waitForJanitorToFinish();
+ Bitmap bitmap = BitmapFactory.decodeFile(cacheFile.getAbsolutePath());
+ if(bitmap != null) {
+ Tools.runOnUiThread(()->{
+ if(taskCancelled()) {
+ bitmap.recycle(); // do not leak the bitmap if the task got cancelled right at the end
+ return;
+ }
+ imageReceiver.onImageAvailable(bitmap);
+ });
+ return;
+ }
+ }
+ if(iconCache.cachePath.canWrite() &&
+ !taskCancelled()) { // don't run the download task if the task got canceled
+ runDownloadTask();
+ }
+ }
+ @SuppressWarnings("BooleanMethodAlwaysInverted")
+ public boolean taskCancelled() {
+ return iconCache.checkCancelled(imageReceiver);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/Constants.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/Constants.java
new file mode 100644
index 000000000..b628a2ae6
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/Constants.java
@@ -0,0 +1,16 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.models;
+
+public class Constants {
+ private Constants(){}
+
+ /** Types of modpack apis */
+ public static final int SOURCE_MODRINTH = 0x0;
+ public static final int SOURCE_CURSEFORGE = 0x1;
+ public static final int SOURCE_TECHNIC = 0x2;
+
+ /** Modrinth api, file environments */
+ public static final String MODRINTH_FILE_ENV_REQUIRED = "required";
+ public static final String MODRINTH_FILE_ENV_OPTIONAL = "optional";
+ public static final String MODRINTH_FILE_ENV_UNSUPPORTED = "unsupported";
+
+}
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..f7b82a4ca
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/CurseManifest.java
@@ -0,0 +1,25 @@
+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 boolean required;
+ }
+ 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/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java
new file mode 100644
index 000000000..12f9ec5e1
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java
@@ -0,0 +1,41 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.models;
+
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+
+public class ModDetail extends ModItem {
+ /* A cheap way to map from the front facing name to the underlying id */
+ public String[] versionNames;
+ public String [] mcVersionNames;
+ public String[] versionUrls;
+ public ModDetail(ModItem item, String[] versionNames, String[] mcVersionNames, String[] versionUrls) {
+ super(item.apiSource, item.isModpack, item.id, item.title, item.description, item.imageUrl);
+ this.versionNames = versionNames;
+ this.mcVersionNames = mcVersionNames;
+ this.versionUrls = versionUrls;
+
+ // Add the mc version to the version model
+ for (int i=0; i dependencies;
+
+
+ public static class ModrinthIndexFile {
+ public String path;
+ public String[] downloads;
+ public int fileSize;
+
+ public ModrinthIndexFileHashes hashes;
+
+ @Nullable public ModrinthIndexFileEnv env;
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "ModrinthIndexFile{" +
+ "path='" + path + '\'' +
+ ", downloads=" + Arrays.toString(downloads) +
+ ", fileSize=" + fileSize +
+ ", hashes=" + hashes +
+ '}';
+ }
+
+ public static class ModrinthIndexFileHashes {
+ public String sha1;
+ public String sha512;
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "ModrinthIndexFileHashes{" +
+ "sha1='" + sha1 + '\'' +
+ ", sha512='" + sha512 + '\'' +
+ '}';
+ }
+ }
+
+ public static class ModrinthIndexFileEnv {
+ public String client;
+ public String server;
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "ModrinthIndexFileEnv{" +
+ "client='" + client + '\'' +
+ ", server='" + server + '\'' +
+ '}';
+ }
+ }
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "ModrinthIndex{" +
+ "formatVersion=" + formatVersion +
+ ", game='" + game + '\'' +
+ ", versionId='" + versionId + '\'' +
+ ", name='" + name + '\'' +
+ ", summary='" + summary + '\'' +
+ ", files=" + Arrays.toString(files) +
+ '}';
+ }
+
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchFilters.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchFilters.java
new file mode 100644
index 000000000..5694b3b1e
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchFilters.java
@@ -0,0 +1,13 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.models;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Search filters, passed to APIs
+ */
+public class SearchFilters {
+ public boolean isModpack;
+ public String name;
+ @Nullable public String mcVersion;
+
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchResult.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchResult.java
new file mode 100644
index 000000000..94638435c
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchResult.java
@@ -0,0 +1,6 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.models;
+
+public class SearchResult {
+ public int totalResultCount;
+ public ModItem[] results;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java
index a3a9266da..91295d5b2 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/profiles/ProfileAdapter.java
@@ -31,15 +31,11 @@ public class ProfileAdapter extends BaseAdapter {
private Map mProfiles;
private final MinecraftProfile dummy = new MinecraftProfile();
private List mProfileList;
- private final ProfileAdapterExtra[] mExtraEntires;
+ private ProfileAdapterExtra[] mExtraEntires;
public ProfileAdapter(Context context, ProfileAdapterExtra[] extraEntries) {
ProfileIconCache.initDefault(context);
- LauncherProfiles.update();
- mProfiles = new HashMap<>(LauncherProfiles.mainProfileJson.profiles);
- if(extraEntries == null) mExtraEntires = new ProfileAdapterExtra[0];
- else mExtraEntires = extraEntries;
- mProfileList = new ArrayList<>(Arrays.asList(mProfiles.keySet().toArray(new String[0])));
+ reloadProfiles(extraEntries);
}
/*
* Gets how much profiles are loaded in the adapter right now
@@ -67,6 +63,8 @@ public Object getItem(int position) {
return null;
}
+
+
public int resolveProfileIndex(String name) {
return mProfileList.indexOf(name);
}
@@ -134,4 +132,19 @@ public void setViewExtra(View v, ProfileAdapterExtra extra) {
extendedTextView.setText(extra.name);
extendedTextView.setBackgroundColor(Color.TRANSPARENT);
}
+
+ /** Reload profiles from the file */
+ public void reloadProfiles(){
+ LauncherProfiles.load();
+ mProfiles = new HashMap<>(LauncherProfiles.mainProfileJson.profiles);
+ mProfileList = new ArrayList<>(Arrays.asList(mProfiles.keySet().toArray(new String[0])));
+ notifyDataSetChanged();
+ }
+
+ /** Reload profiles from the file, with additional extra entries */
+ public void reloadProfiles(ProfileAdapterExtra[] extraEntries) {
+ if(extraEntries == null) mExtraEntires = new ProfileAdapterExtra[0];
+ else mExtraEntires = extraEntries;
+ this.reloadProfiles();
+ }
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/progresskeeper/DownloaderProgressWrapper.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/progresskeeper/DownloaderProgressWrapper.java
new file mode 100644
index 000000000..bf2818186
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/progresskeeper/DownloaderProgressWrapper.java
@@ -0,0 +1,40 @@
+package net.kdt.pojavlaunch.progresskeeper;
+
+import static net.kdt.pojavlaunch.Tools.BYTE_TO_MB;
+
+import net.kdt.pojavlaunch.Tools;
+
+public class DownloaderProgressWrapper implements Tools.DownloaderFeedback {
+
+ private final int mProgressString;
+ private final String mProgressRecord;
+ public String extraString = null;
+
+ /**
+ * A simple wrapper to send the downloader progress to ProgressKeeper
+ * @param progressString the string that will be used in the progress reporter
+ * @param progressRecord the record for ProgressKeeper
+ */
+ public DownloaderProgressWrapper(int progressString, String progressRecord) {
+ this.mProgressString = progressString;
+ this.mProgressRecord = progressRecord;
+ }
+
+ @Override
+ public void updateProgress(int curr, int max) {
+ Object[] va;
+ if(extraString != null) {
+ va = new Object[3];
+ va[0] = extraString;
+ va[1] = curr/BYTE_TO_MB;
+ va[2] = max/BYTE_TO_MB;
+ }
+ else {
+ va = new Object[2];
+ va[0] = curr/BYTE_TO_MB;
+ va[1] = max/BYTE_TO_MB;
+ }
+ // the allocations are fine because thats how java implements variadic arguments in bytecode: an array of whatever
+ ProgressKeeper.submitProgress(mProgressRecord, (int) Math.max((float)curr/max*100,0), mProgressString, va);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/GameService.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/GameService.java
index 00babeca1..a88416961 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/GameService.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/GameService.java
@@ -14,6 +14,7 @@
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.value.NotificationConstants;
import java.lang.ref.WeakReference;
@@ -38,14 +39,15 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
Intent killIntent = new Intent(getApplicationContext(), GameService.class);
killIntent.putExtra("kill", true);
- PendingIntent pendingKillIntent = PendingIntent.getService(this, 0, killIntent, Build.VERSION.SDK_INT >=23 ? PendingIntent.FLAG_IMMUTABLE : 0);
+ PendingIntent pendingKillIntent = PendingIntent.getService(this, NotificationConstants.PENDINGINTENT_CODE_KILL_GAME_SERVICE
+ , killIntent, Build.VERSION.SDK_INT >=23 ? PendingIntent.FLAG_IMMUTABLE : 0);
NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, "channel_id")
.setContentTitle(getString(R.string.lazy_service_default_title))
.setContentText(getString(R.string.notification_game_runs))
.addAction(android.R.drawable.ic_menu_close_clear_cancel, getString(R.string.notification_terminate), pendingKillIntent)
.setSmallIcon(R.drawable.notif_icon)
.setNotificationSilent();
- startForeground(2, notificationBuilder.build());
+ startForeground(NotificationConstants.NOTIFICATION_ID_GAME_SERVICE, notificationBuilder.build());
return START_NOT_STICKY; // non-sticky so android wont try restarting the game after the user uses the "Quit" button
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/ProgressService.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/ProgressService.java
index b5fc6396b..2ffa71eb9 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/ProgressService.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/services/ProgressService.java
@@ -19,6 +19,7 @@
import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
import net.kdt.pojavlaunch.progresskeeper.TaskCountListener;
+import net.kdt.pojavlaunch.value.NotificationConstants;
/**
* Lazy service which allows the process not to get killed.
@@ -42,7 +43,8 @@ public void onCreate() {
notificationManagerCompat = NotificationManagerCompat.from(getApplicationContext());
Intent killIntent = new Intent(getApplicationContext(), ProgressService.class);
killIntent.putExtra("kill", true);
- PendingIntent pendingKillIntent = PendingIntent.getService(this, 0, killIntent, Build.VERSION.SDK_INT >=23 ? PendingIntent.FLAG_IMMUTABLE : 0);
+ PendingIntent pendingKillIntent = PendingIntent.getService(this, NotificationConstants.PENDINGINTENT_CODE_KILL_PROGRESS_SERVICE
+ , killIntent, Build.VERSION.SDK_INT >=23 ? PendingIntent.FLAG_IMMUTABLE : 0);
mNotificationBuilder = new NotificationCompat.Builder(this, "channel_id")
.setContentTitle(getString(R.string.lazy_service_default_title))
.addAction(android.R.drawable.ic_menu_close_clear_cancel, getString(R.string.notification_terminate), pendingKillIntent)
@@ -62,7 +64,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
Log.d("ProgressService", "Started!");
mNotificationBuilder.setContentText(getString(R.string.progresslayout_tasks_in_progress, ProgressKeeper.getTaskCount()));
- startForeground(1, mNotificationBuilder.build());
+ startForeground(NotificationConstants.NOTIFICATION_ID_PROGRESS_SERVICE, mNotificationBuilder.build());
if(ProgressKeeper.getTaskCount() < 1) stopSelf();
else ProgressKeeper.addTaskCountListener(this, false);
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncMinecraftDownloader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncMinecraftDownloader.java
index 74112fefd..4de6935c3 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncMinecraftDownloader.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncMinecraftDownloader.java
@@ -1,6 +1,7 @@
package net.kdt.pojavlaunch.tasks;
import static net.kdt.pojavlaunch.PojavApplication.sExecutorService;
+import static net.kdt.pojavlaunch.Tools.BYTE_TO_MB;
import static net.kdt.pojavlaunch.utils.DownloadUtils.downloadFileMonitored;
import android.app.Activity;
@@ -40,7 +41,7 @@
import java.util.concurrent.atomic.AtomicInteger;
public class AsyncMinecraftDownloader {
- private static final float BYTE_TO_MB = 1024 * 1024;
+
public static final String MINECRAFT_RES = "https://resources.download.minecraft.net/";
/* Allows each downloading thread to have its own RECYCLED buffer */
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/FileUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/FileUtils.java
index 44cc8c5fb..2256c6cc5 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/FileUtils.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/FileUtils.java
@@ -1,9 +1,20 @@
package net.kdt.pojavlaunch.utils;
import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
public class FileUtils {
public static boolean exists(String filePath){
return new File(filePath).exists();
}
+
+ public static String getFileName(String pathOrUrl) {
+ int lastSlashIndex = pathOrUrl.lastIndexOf('/');
+ if(lastSlashIndex == -1) return null;
+ return pathOrUrl.substring(lastSlashIndex);
+ }
}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/ZipUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/ZipUtils.java
new file mode 100644
index 000000000..a56fd661b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/ZipUtils.java
@@ -0,0 +1,58 @@
+package net.kdt.pojavlaunch.utils;
+
+import org.apache.commons.io.IOUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Enumeration;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+public class ZipUtils {
+ /**
+ * Gets an InputStream for a given ZIP entry, throwing an IOException if the ZIP entry does not
+ * exist.
+ * @param zipFile The ZipFile to get the entry from
+ * @param entryPath The full path inside of the ZipFile
+ * @return The InputStream provided by the ZipFile
+ * @throws IOException if the entry was not found
+ */
+ public static InputStream getEntryStream(ZipFile zipFile, String entryPath) throws IOException{
+ ZipEntry entry = zipFile.getEntry(entryPath);
+ if(entry == null) throw new IOException("No entry in ZIP file: "+entryPath);
+ return zipFile.getInputStream(entry);
+ }
+
+ /**
+ * Extracts all files in a ZipFile inside of a given directory to a given destination directory
+ * How to specify dirName:
+ * If you want to extract all files in the ZipFile, specify ""
+ * If you want to extract a single directory, specify its full path followed by a trailing /
+ * @param zipFile The ZipFile to extract files from
+ * @param dirName The directory to extract the files from
+ * @param destination The destination directory to extract the files into
+ * @throws IOException if it was not possible to create a directory or file extraction failed
+ */
+ public static void zipExtract(ZipFile zipFile, String dirName, File destination) throws IOException {
+ Enumeration extends ZipEntry> zipEntries = zipFile.entries();
+
+ int dirNameLen = dirName.length();
+ while(zipEntries.hasMoreElements()) {
+ ZipEntry zipEntry = zipEntries.nextElement();
+ String entryName = zipEntry.getName();
+ if(!entryName.startsWith(dirName) || zipEntry.isDirectory()) continue;
+ File zipDestination = new File(destination, entryName.substring(dirNameLen));
+ File parent = zipDestination.getParentFile();
+ if(parent != null && !parent.exists())
+ if(!parent.mkdirs()) throw new IOException("Failed to create "+parent.getAbsolutePath());
+ try (InputStream inputStream = zipFile.getInputStream(zipEntry);
+ OutputStream outputStream =
+ new FileOutputStream(zipDestination)) {
+ IOUtils.copy(inputStream, outputStream);
+ }
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NotificationConstants.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NotificationConstants.java
new file mode 100644
index 000000000..02c23596e
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NotificationConstants.java
@@ -0,0 +1,12 @@
+package net.kdt.pojavlaunch.value;
+
+public class NotificationConstants {
+ public static final int NOTIFICATION_ID_PROGRESS_SERVICE = 1;
+ public static final int NOTIFICATION_ID_GAME_SERVICE = 2;
+ public static final int NOTIFICATION_ID_DOWNLOAD_LISTENER = 3;
+ public static final int NOTIFICATION_ID_SHOW_ERROR = 4;
+ public static final int PENDINGINTENT_CODE_KILL_PROGRESS_SERVICE = 1;
+ public static final int PENDINGINTENT_CODE_KILL_GAME_SERVICE = 2;
+ public static final int PENDINGINTENT_CODE_DOWNLOAD_SERVICE = 3;
+ public static final int PENDINGINTENT_CODE_SHOW_ERROR = 4;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/launcherprofiles/LauncherProfiles.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/launcherprofiles/LauncherProfiles.java
index 75af9c474..7ab6b407c 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/launcherprofiles/LauncherProfiles.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/launcherprofiles/LauncherProfiles.java
@@ -8,45 +8,51 @@
import net.kdt.pojavlaunch.prefs.LauncherPreferences;
import java.io.File;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.UUID;
public class LauncherProfiles {
public static MinecraftLauncherProfiles mainProfileJson;
- public static final File launcherProfilesFile = new File(Tools.DIR_GAME_NEW, "launcher_profiles.json");
- public static MinecraftLauncherProfiles update() {
- try {
- if (mainProfileJson == null) {
- if (launcherProfilesFile.exists()) {
- mainProfileJson = Tools.GLOBAL_GSON.fromJson(Tools.read(launcherProfilesFile.getAbsolutePath()), MinecraftLauncherProfiles.class);
- if(mainProfileJson.profiles == null) mainProfileJson.profiles = new HashMap<>();
- else if(LauncherProfiles.normalizeProfileIds(mainProfileJson)){
- LauncherProfiles.update();
- }
- } else {
- mainProfileJson = new MinecraftLauncherProfiles();
- mainProfileJson.profiles = new HashMap<>();
- }
+ private static final File launcherProfilesFile = new File(Tools.DIR_GAME_NEW, "launcher_profiles.json");
- // Make sure we have a default profile on start
- if (mainProfileJson.profiles.size() == 0){
- mainProfileJson.profiles.put("(Default)", MinecraftProfile.getDefaultProfile());
- LauncherProfiles.update();
- }
- } else {
- Tools.write(launcherProfilesFile.getAbsolutePath(), mainProfileJson.toJson());
+ /** Reload the profile from the file, creating a default one if necessary */
+ public static void load(){
+ if (launcherProfilesFile.exists()) {
+ try {
+ mainProfileJson = Tools.GLOBAL_GSON.fromJson(Tools.read(launcherProfilesFile.getAbsolutePath()), MinecraftLauncherProfiles.class);
+ } catch (IOException e) {
+ Log.e(LauncherProfiles.class.toString(), "Failed to load file: ", e);
+ throw new RuntimeException(e);
}
+ }
- // insertMissing();
- return mainProfileJson;
- } catch (Throwable th) {
- throw new RuntimeException(th);
+ // Fill with default
+ if (mainProfileJson == null) mainProfileJson = new MinecraftLauncherProfiles();
+ if (mainProfileJson.profiles == null) mainProfileJson.profiles = new HashMap<>();
+ if (mainProfileJson.profiles.size() == 0)
+ mainProfileJson.profiles.put(UUID.randomUUID().toString(), MinecraftProfile.getDefaultProfile());
+
+ // Normalize profile names from mod installers
+ if(normalizeProfileIds(mainProfileJson)){
+ write();
+ load();
+ }
+ }
+
+ /** Apply the current configuration into a file */
+ public static void write() {
+ try {
+ Tools.write(launcherProfilesFile.getAbsolutePath(), mainProfileJson.toJson());
+ } catch (IOException e) {
+ Log.e(LauncherProfiles.class.toString(), "Failed to write profile file", e);
+ throw new RuntimeException(e);
}
}
public static @NonNull MinecraftProfile getCurrentProfile() {
- if(mainProfileJson == null) LauncherProfiles.update();
+ if(mainProfileJson == null) LauncherProfiles.load();
String defaultProfileName = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, "");
MinecraftProfile profile = mainProfileJson.profiles.get(defaultProfileName);
if(profile == null) throw new RuntimeException("The current profile stopped existing :(");
diff --git a/app_pojavlauncher/src/main/res/drawable/background_overlay.xml b/app_pojavlauncher/src/main/res/drawable/background_overlay.xml
new file mode 100644
index 000000000..91493c05e
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/drawable/background_overlay.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/app_pojavlauncher/src/main/res/drawable/ic_curseforge.png b/app_pojavlauncher/src/main/res/drawable/ic_curseforge.png
new file mode 100644
index 000000000..2e19dc710
Binary files /dev/null and b/app_pojavlauncher/src/main/res/drawable/ic_curseforge.png differ
diff --git a/app_pojavlauncher/src/main/res/drawable/ic_filter.xml b/app_pojavlauncher/src/main/res/drawable/ic_filter.xml
new file mode 100644
index 000000000..b03044595
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/drawable/ic_filter.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app_pojavlauncher/src/main/res/drawable/ic_modrinth.png b/app_pojavlauncher/src/main/res/drawable/ic_modrinth.png
new file mode 100644
index 000000000..0d7b08659
Binary files /dev/null and b/app_pojavlauncher/src/main/res/drawable/ic_modrinth.png differ
diff --git a/app_pojavlauncher/src/main/res/layout/dialog_mod_filters.xml b/app_pojavlauncher/src/main/res/layout/dialog_mod_filters.xml
new file mode 100644
index 000000000..911f02b39
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/layout/dialog_mod_filters.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/layout/fragment_mod_search.xml b/app_pojavlauncher/src/main/res/layout/fragment_mod_search.xml
new file mode 100644
index 000000000..292f308a7
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/layout/fragment_mod_search.xml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/layout/fragment_profile_type.xml b/app_pojavlauncher/src/main/res/layout/fragment_profile_type.xml
index 8624645c5..73bbdc292 100644
--- a/app_pojavlauncher/src/main/res/layout/fragment_profile_type.xml
+++ b/app_pojavlauncher/src/main/res/layout/fragment_profile_type.xml
@@ -103,6 +103,14 @@
app:layout_constraintTop_toBottomOf="@+id/modded_profile_fabric" />
+
+
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/layout/view_mod.xml b/app_pojavlauncher/src/main/res/layout/view_mod.xml
new file mode 100644
index 000000000..863ce17dc
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/layout/view_mod.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/layout/view_mod_extended.xml b/app_pojavlauncher/src/main/res/layout/view_mod_extended.xml
new file mode 100644
index 000000000..c3e4f2230
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/layout/view_mod_extended.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/values/colors.xml b/app_pojavlauncher/src/main/res/values/colors.xml
index 847f9ec78..ed07e6cbd 100644
--- a/app_pojavlauncher/src/main/res/values/colors.xml
+++ b/app_pojavlauncher/src/main/res/values/colors.xml
@@ -5,6 +5,7 @@
#57CC33
#181818
+ #464646
#242424
#232323
diff --git a/app_pojavlauncher/src/main/res/values/strings.xml b/app_pojavlauncher/src/main/res/values/strings.xml
index a6779e5e1..929785ee1 100644
--- a/app_pojavlauncher/src/main/res/values/strings.xml
+++ b/app_pojavlauncher/src/main/res/values/strings.xml
@@ -384,6 +384,7 @@
Forces the Minecraft render thread to run on the core with the highest max frequency
Select a version
Downloading installer for %s
+ Searching for Forge version number
Failed to load the version list!
Sorry, but this version of Forge does not have an installer, which is not yet supported.
Select Forge version
@@ -406,7 +407,29 @@
Smoothing time
Reduce jitter in exchange of latency. 0 to disable it
-
+ Search for modpacks
+
+ Install
+ Apply
+
+ 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)
+ Downloading mods (File %d out of %d)
+ Applying overrides (%d/%d)
+ Pojav Modpack Installer
+ Click here to finish modpack installation
+ Failed to download mod loader information
+ Failed to download the mod loader files
+ Failed to download modpack files
+ Create modpack profile
+
+ An error has occurred
+ Click to see more details
+
Gestures
Buttons
diff --git a/forge_installer/src/main/java/git/artdeell/installer_agent/Agent.java b/forge_installer/src/main/java/git/artdeell/installer_agent/Agent.java
index dc2cf22c2..53a7361ae 100644
--- a/forge_installer/src/main/java/git/artdeell/installer_agent/Agent.java
+++ b/forge_installer/src/main/java/git/artdeell/installer_agent/Agent.java
@@ -21,11 +21,13 @@ public class Agent implements AWTEventListener {
private boolean forgeWindowHandled = false;
private final boolean suppressProfileCreation;
private final boolean optiFineInstallation;
+ private final String modpackFixupId;
private final Timer componentTimer = new Timer();
- public Agent(boolean nps, boolean of) {
+ public Agent(boolean nps, boolean of, String mf) {
this.suppressProfileCreation = !nps;
this.optiFineInstallation = of;
+ this.modpackFixupId = mf;
}
@Override
@@ -104,7 +106,7 @@ public void handleDialog(Window window) {
JOptionPane optionPane = (JOptionPane) components.get(0);
if(optionPane.getMessageType() == JOptionPane.INFORMATION_MESSAGE) { // forge doesn't emit information messages for other reasons yet
System.out.println("The install was successful!");
- ProfileFixer.reinsertProfile(optiFineInstallation ? "OptiFine" : "forge", suppressProfileCreation);
+ ProfileFixer.reinsertProfile(optiFineInstallation ? "OptiFine" : "forge", modpackFixupId, suppressProfileCreation);
System.exit(0); // again, forge doesn't call exit for some reason, so we do that ourselves here
}
}
@@ -124,13 +126,30 @@ public void insertAllComponents(List components, Container parent, Co
public static void premain(String args, Instrumentation inst) {
boolean noProfileSuppression = false;
boolean optifine = false;
+ String modpackFixupId = null;
if(args != null ) {
- noProfileSuppression = args.contains("NPS"); // No Profile Suppression
- optifine = args.contains("OF"); // OptiFine
+ modpackFixupId = findQuotedString(args);
+ if(modpackFixupId != null) {
+ noProfileSuppression = args.contains("NPS") && !modpackFixupId.contains("NPS");
+ // No Profile Suppression
+ optifine = args.contains("OF") && !modpackFixupId.contains("OF");
+ // OptiFine
+ }else {
+ noProfileSuppression = args.contains("NPS"); // No Profile Suppression
+ optifine = args.contains("OF"); // OptiFine
+ }
}
- Agent agent = new Agent(noProfileSuppression, optifine);
+ Agent agent = new Agent(noProfileSuppression, optifine, modpackFixupId);
Toolkit.getDefaultToolkit()
.addAWTEventListener(agent,
AWTEvent.WINDOW_EVENT_MASK);
}
+
+ private static String findQuotedString(String args) {
+ int quoteIndex = args.indexOf('"');
+ if(quoteIndex == -1) return null;
+ int nextQuoteIndex = args.indexOf('"', quoteIndex+1);
+ if(nextQuoteIndex == -1) return null;
+ return args.substring(quoteIndex+1, nextQuoteIndex);
+ }
}
diff --git a/forge_installer/src/main/java/git/artdeell/installer_agent/ProfileFixer.java b/forge_installer/src/main/java/git/artdeell/installer_agent/ProfileFixer.java
index da6e72967..5dadb711d 100644
--- a/forge_installer/src/main/java/git/artdeell/installer_agent/ProfileFixer.java
+++ b/forge_installer/src/main/java/git/artdeell/installer_agent/ProfileFixer.java
@@ -10,6 +10,7 @@
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Random;
+import java.util.Set;
public class ProfileFixer {
private static final Random random = new Random();
@@ -22,7 +23,8 @@ public static void storeProfile(String profileName) {
StandardCharsets.UTF_8)
);
JSONObject profilesArray = minecraftProfiles.getJSONObject("profiles");
- oldProfile = profilesArray.optJSONObject(profileName, null);
+ profileName = findProfileName(profileName, profilesArray);
+ oldProfile = profileName != null ? minecraftProfiles.getJSONObject(profileName) : null;
}catch (IOException | JSONException e) {
System.out.println("Failed to store Forge profile: "+e);
}
@@ -31,22 +33,24 @@ public static void storeProfile(String profileName) {
private static String pickProfileName(String profileName) {
return profileName+random.nextInt();
}
- public static void reinsertProfile(String profileName, boolean suppressProfileCreation) {
+ public static void reinsertProfile(String profileName, String modpackFixupId, boolean suppressProfileCreation) {
try {
JSONObject minecraftProfiles = new JSONObject(
new String(Files.readAllBytes(profilesPath),
StandardCharsets.UTF_8)
);
JSONObject profilesArray = minecraftProfiles.getJSONObject("profiles");
+ profileName = findProfileName(profileName, profilesArray);
+ if(modpackFixupId != null) fixupModpackProfile(profileName, modpackFixupId, profilesArray);
if(oldProfile != null) {
- if(suppressProfileCreation) profilesArray.put("forge", oldProfile); // restore the old profile
+ if(suppressProfileCreation) profilesArray.put(profileName, oldProfile); // restore the old profile
else {
String name = pickProfileName(profileName);
while(profilesArray.has(name)) name = pickProfileName(profileName);
profilesArray.put(name, oldProfile); // restore the old profile under a new name
}
}else{
- if(suppressProfileCreation) profilesArray.remove("forge"); // remove the new profile
+ if(suppressProfileCreation) profilesArray.remove(profileName); // remove the new profile
// otherwise it wont be removed
}
minecraftProfiles.put("profiles", profilesArray);
@@ -56,4 +60,35 @@ public static void reinsertProfile(String profileName, boolean suppressProfileCr
System.out.println("Failed to restore old Forge profile: "+e);
}
}
+
+ private static void fixupModpackProfile(String profileId, String expectedVersionId, JSONObject profilesArray) {
+ System.out.println("Fixing up modpack profile version ID...");
+ JSONObject modloaderProfile = profilesArray.optJSONObject(profileId);
+ if(modloaderProfile == null) {
+ System.out.println("Failed to find the modloader profile, keys:" + profilesArray.keySet().toString());
+ return;
+ }
+ String modloaderVersionId = modloaderProfile.optString("lastVersionId");
+ if(modloaderVersionId == null) {
+ System.out.println("Failed to find the modloader profile version, keys:" + modloaderProfile.keySet().toString());
+ return;
+ }
+ System.out.println("Expected version ID: "+expectedVersionId+" Modloader version ID: "+modloaderVersionId);
+ if(expectedVersionId.equals(modloaderVersionId)) return;
+ for(String profileKey : profilesArray.keySet()) {
+ if(profileKey.equals(profileId)) continue;
+ JSONObject profile = profilesArray.getJSONObject(profileKey);
+ if(!expectedVersionId.equals(profile.optString("lastVersionId"))) continue;
+ profile.put("lastVersionId", modloaderVersionId);
+ System.out.println("Replacing version ID in profile "+profileKey);
+ }
+ }
+
+ private static String findProfileName(String profileId, JSONObject profilesArray) {
+ Set profiles = profilesArray.keySet();
+ for(String profile : profiles) {
+ if(profile.equalsIgnoreCase(profileId)) return profile;
+ }
+ return null;
+ }
}