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 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 @@ + + + + + + + + +